onejs-sl 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/body.ts CHANGED
@@ -21,7 +21,7 @@
21
21
  * gives common subexpression elimination for free.
22
22
  */
23
23
 
24
- import { INT_BOUNDS, SLError, TYPE, valueAt, type InputName, type NodeRef, type StepInputName, type Program, type SLKind, type SLType, type ValueNode } from "./ir"
24
+ import { INT_BOUNDS, SLError, TYPE, isWhole, samplingOf, valueAt, type TextureSampling, type InputName, type NodeRef, type StepInputName, 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"
@@ -55,7 +55,8 @@ export interface BodyTarget {
55
55
  * - **Filtered and wrapped by the host.** The body does neither. OneJS's
56
56
  * hosts, the generated shader and the web backends alike, use the
57
57
  * bound texture's own filter and wrap modes, so a host that filters by
58
- * hand draws the same picture by following the texture's settings.
58
+ * hand draws the same picture by following the texture's settings. A
59
+ * texture whose program sets its sampler goes to `sampleWith` instead.
59
60
  *
60
61
  * What becomes of the result is the host's: OneJS writes it to the target
61
62
  * as it is, and a host that composites premultiplied multiplies on write.
@@ -63,12 +64,50 @@ export interface BodyTarget {
63
64
  sample: (slot: number, uv: string) => string
64
65
  /**
65
66
  * A float4 sample of the texture in `slot` at `uv` from mip level `lod`, a
66
- * float expression: 0 is the full size texture, 1 half, and a fraction
67
- * blends two levels where the texture's filter does. Otherwise the same
68
- * contract as `sample`. `tex2Dlod` in a program; HLSL's
67
+ * float expression: 0 is the full size texture and 1 half. The body only
68
+ * asks for whole levels, or for a level it cannot prove whole, floored and
69
+ * floored plus one, which it blends by the fraction itself; so a fraction
70
+ * draws the same whatever the texture's filter between levels, and a host
71
+ * has no blending to do. A level past the smallest reads the smallest.
72
+ * Otherwise the same contract as `sample`. `tex2Dlod` in a program; HLSL's
69
73
  * `tex2Dlod(s, float4(uv, 0, lod))` and Metal's `sample(s, uv, level(lod))`.
70
74
  */
71
75
  sampleLevel: (slot: number, uv: string, lod: string) => string
76
+ /**
77
+ * A float4 sample of a texture whose program sets its sampler
78
+ * (`[Wrap(Repeat)]`, `[Filter(Point)]`, IR 6), sampled that way whatever
79
+ * the bound texture's own settings: `sample` when `lod` is null, else
80
+ * `sampleLevel` at `lod`, with the same contract as those two in every
81
+ * other respect. `sampling` always has both halves (`samplingOf`):
82
+ *
83
+ * - `wrap`: `repeat` tiles, `mirror` tiles mirrored every other tile,
84
+ * `clamp` holds the edge texel. The filter's footprint wraps the same
85
+ * way, so a tiled texture has no seam.
86
+ * - `filter`: `point` reads the nearest texel and the nearest level,
87
+ * `linear` blends the four nearest texels within the nearest level, as
88
+ * Unity's Bilinear does.
89
+ *
90
+ * Optional, and `emitBody` refuses a program that samples such a texture
91
+ * when a target has none, rather than print one that samples as the
92
+ * bound texture is set. Unity's inline sampler states, a WebGPU sampler
93
+ * and a WebGL2 sampler object each say both halves.
94
+ */
95
+ sampleWith?: (slot: number, uv: string, lod: string | null, sampling: TextureSampling) => string
96
+ /**
97
+ * A float2 of the texture in `slot`'s width and height, in pixels, at full
98
+ * size (`textureSize(t)`, IR 6). Optional, as `previous` is.
99
+ */
100
+ size?: (slot: number) => string
101
+ /**
102
+ * A float4 of one texel of the texture in `slot`, unfiltered, at full size
103
+ * (`tex2Dfetch(t, pixel)`, IR 6). `pixel` is a float2 expression the body
104
+ * has already floored and held inside the texture with `size`, so a host
105
+ * converts it to an int2 and loads: HLSL's `t.Load(int3(pixel, 0))`,
106
+ * Metal's `t.read(uint2(pixel))`. Texel (0, 0) is the one `uv` (0, 0)
107
+ * samples. In the space `colour` names, as `sample` is, so an sRGB texture
108
+ * is decoded. Optional; needs `size` too.
109
+ */
110
+ fetch?: (slot: number, pixel: string) => string
72
111
  /**
73
112
  * A float4 of what this program drew the frame before, at `uv`, for a
74
113
  * host that keeps it (`tex2D(previous, uv)`, `Specs/SL_NEXT.md` 4).
@@ -110,7 +149,7 @@ export interface Body {
110
149
  uses: {
111
150
  /** Uniform slots the body reads, ascending. */
112
151
  uniforms: number[]
113
- /** Texture slots the body samples, ascending. */
152
+ /** Texture slots the body samples, measures or fetches from, ascending. */
114
153
  textures: number[]
115
154
  /** Library functions the body calls, sorted: `sl_fbm`, `sl_sdfDistance`, ... `emitLibrary` prints them. */
116
155
  helpers: string[]
@@ -165,6 +204,26 @@ export function emitBody(p: Program, target: BodyTarget): Body {
165
204
  const c = p.nodes[ref]!
166
205
  return c.k === "const" ? c.v : null
167
206
  }
207
+ /** A texture's sampler, when its program sets one, and the target that has to honour it. */
208
+ const sampling = (slot: number): TextureSampling | null => {
209
+ const s = samplingOf(p.textures[slot]!)
210
+ if (s !== null && target.sampleWith === undefined) {
211
+ throw new SLError(`this target cannot set a texture's sampler, and the program samples ${p.textures[slot]!.name} with ${s.wrap} and ${s.filter}`)
212
+ }
213
+ return s
214
+ }
215
+ const plain = (slot: number, uv: string) => {
216
+ const s = sampling(slot)
217
+ return s === null ? target.sample(slot, uv) : target.sampleWith!(slot, uv, null, s)
218
+ }
219
+ const level = (slot: number, uv: string, lod: string) => {
220
+ const s = sampling(slot)
221
+ return s === null ? target.sampleLevel(slot, uv, lod) : target.sampleWith!(slot, uv, lod, s)
222
+ }
223
+ const size = (slot: number) => {
224
+ if (target.size === undefined) throw new SLError(`this target cannot measure a texture, and the program measures ${p.textures[slot]!.name}`)
225
+ return target.size(slot)
226
+ }
168
227
  const splat = (ref: number, w: SLType) => (valueAt(p.nodes, ref).type === TYPE.FLOAT && w > 1 ? ctor(w, Array(w).fill(name(ref))) : name(ref))
169
228
 
170
229
  const expr = (ref: NodeRef): string => {
@@ -331,12 +390,32 @@ export function emitBody(p: Program, target: BodyTarget): Body {
331
390
  textures.add(slot)
332
391
  // Where pixels part ways there are no neighbours to take a mip
333
392
  // level from, so every backend samples level 0 there (`structure.ts`).
334
- return inVaryingFlow(shape, ref) ? target.sampleLevel(slot, a[0]!, "0.0") : target.sample(slot, a[0]!)
393
+ return inVaryingFlow(shape, ref) ? level(slot, a[0]!, "0.0") : plain(slot, a[0]!)
335
394
  }
336
395
  case SLOP.SAMPLE_LOD: {
337
396
  const slot = Math.round(imm[0] ?? 0)
338
397
  textures.add(slot)
339
- return target.sampleLevel(slot, a[0]!, a[1]!)
398
+ // A fraction blends the level below and the level above,
399
+ // whatever the texture's filter between levels, as the web
400
+ // emitters do (`isWhole`). Past the smallest level both read the
401
+ // smallest, as a whole level past it does.
402
+ if (isWhole(p.nodes, n.args[1]!)) return level(slot, a[0]!, a[1]!)
403
+ const below = `floor(${a[1]})`
404
+ return `lerp(${level(slot, a[0]!, below)}, ${level(slot, a[0]!, `${below} + 1.0`)}, frac(${a[1]}))`
405
+ }
406
+ case SLOP.TEXTURE_SIZE: {
407
+ const slot = Math.round(imm[0] ?? 0)
408
+ textures.add(slot)
409
+ return size(slot)
410
+ }
411
+ case SLOP.SAMPLE_FETCH: {
412
+ const slot = Math.round(imm[0] ?? 0)
413
+ textures.add(slot)
414
+ if (target.fetch === undefined) throw new SLError(`this target cannot fetch a texel, and the program reads ${p.textures[slot]!.name} with tex2Dfetch`)
415
+ // Floored and held inside, so a pixel past an edge reads the
416
+ // edge on every host rather than whatever a host's load returns
417
+ // out of range.
418
+ return target.fetch(slot, `clamp(floor(${a[0]}), float2(0.0, 0.0), ${size(slot)} - 1.0)`)
340
419
  }
341
420
  case SLOP.SAMPLE_PREVIOUS:
342
421
  if (target.previous === undefined) throw new SLError("this target keeps no previous frame, and the program samples it")
package/src/core.ts CHANGED
@@ -8,13 +8,14 @@
8
8
  * game never executes.
9
9
  */
10
10
  export * as sl from "./sl"
11
- export type { Float, Vec2, Vec3, Vec4, Num, ProgramInputs, PreviousFrame, Texture } from "./sl"
11
+ export type { Float, Vec2, Vec3, Vec4, Num, ProgramInputs, PreviousFrame, Texture, TextureSamplingOptions } from "./sl"
12
12
  export {
13
13
  TYPE, INPUTS, STEP_INPUTS, PREVIOUS, MAX_NODES, SLError, hashProgram, widthName, SL_IR_VERSION, SL_HASH_VERSION,
14
- inputsUsed, readsOf, CONTROL_FIELDS, controlProblem,
14
+ inputsUsed, readsOf, CONTROL_FIELDS, controlProblem, samplingOf, TEXTURE_WRAPS, TEXTURE_FILTERS,
15
15
  } from "./ir"
16
16
  export type {
17
17
  SLType, InputName, StepInputName, HostInputName, SLReads, NodeRef, SLNode, Program, UniformDecl, UniformControl, TextureDecl,
18
+ TextureSampling, TextureWrap, TextureFilter,
18
19
  } from "./ir"
19
20
  export { SLOP, SL_ARITY, SL_NAME, INPUT_ID, isSampling, UNIFORM_SLOTS, TEXTURE_SLOTS } from "./ops"
20
21
  export type { SLOpCode } from "./ops"
package/src/hlsl.ts CHANGED
@@ -15,8 +15,9 @@
15
15
  * next time it is generated.
16
16
  */
17
17
 
18
- import { SLError, SL_HASH_VERSION, readsOf, type Program } from "./ir"
18
+ import { SLError, SL_HASH_VERSION, readsOf, reachable, samplingOf, type Program, type TextureSampling } from "./ir"
19
19
  import { emitBody, lit, type BodyTarget } from "./body"
20
+ import { SLOP } from "./ops"
20
21
 
21
22
  /** The property name a uniform gets. Prefixed so it cannot collide with ours. */
22
23
  export function uniformProperty(name: string): string {
@@ -49,6 +50,7 @@ export function emitShader(p: Program, options: EmitOptions = {}): string {
49
50
  const include = options.include ?? "SLCommon.cginc"
50
51
  const body = emitFragmentBody(p)
51
52
  const reads = readsOf(p)
53
+ const use = textureUse(p)
52
54
 
53
55
  const props: string[] = [
54
56
  ` _Secs ("Seconds", Float) = 0`,
@@ -68,7 +70,22 @@ export function emitShader(p: Program, options: EmitOptions = {}): string {
68
70
  }
69
71
  for (const t of p.textures) {
70
72
  props.push(` _Tex${t.slot} ("${t.name}", 2D) = "white" {}`)
71
- decls.push(` sampler2D _Tex${t.slot};`)
73
+ // A texture sampled as it is set is the sampler2D it always was, so
74
+ // every program before IR 6 prints the shader it always did. One the
75
+ // program sets a sampler for, or fetches from, is a Texture2D: the
76
+ // inline sampler state or the Load needs the texture on its own.
77
+ if (!use.object.has(t.slot)) decls.push(` sampler2D _Tex${t.slot};`)
78
+ else {
79
+ decls.push(` Texture2D _Tex${t.slot};`)
80
+ // Its own settings, by Unity's naming rule, for a plain sample.
81
+ if (use.plain.has(t.slot)) decls.push(` SamplerState sampler_Tex${t.slot};`)
82
+ }
83
+ // Unity fills it from the bound texture: (1 / width, 1 / height, width, height).
84
+ if (use.sized.has(t.slot)) decls.push(` float4 _Tex${t.slot}_TexelSize;`)
85
+ }
86
+ // Unity's inline sampler states: the name says the filter and the wrap.
87
+ for (const name of [...new Set(p.textures.map(samplingOf).filter((s) => s !== null).map(inlineSampler))].sort()) {
88
+ decls.push(` SamplerState ${name};`)
72
89
  }
73
90
  // Only for a program that samples it, so every other program's shader is
74
91
  // the text it always was. Transparent black is the previous frame's clear
@@ -143,6 +160,7 @@ ${body}
143
160
  * shader compile, so this target always emits it.
144
161
  */
145
162
  function unityTarget(p: Program): BodyTarget {
163
+ const use = textureUse(p)
146
164
  return {
147
165
  // `_Res` is the TARGET's size, set by the host, not `_ScreenParams`.
148
166
  //
@@ -164,8 +182,15 @@ function unityTarget(p: Program): BodyTarget {
164
182
  deltaTime: "_Res.w",
165
183
  },
166
184
  uniform: (slot) => uniformProperty(p.uniforms[slot]!.name),
167
- sample: (slot, uv) => `tex2D(_Tex${slot}, ${uv})`,
168
- sampleLevel: (slot, uv, lod) => `tex2Dlod(_Tex${slot}, float4(${uv}, 0.0, ${lod}))`,
185
+ sample: (slot, uv) => use.object.has(slot) ? `_Tex${slot}.Sample(sampler_Tex${slot}, ${uv})` : `tex2D(_Tex${slot}, ${uv})`,
186
+ sampleLevel: (slot, uv, lod) => use.object.has(slot)
187
+ ? `_Tex${slot}.SampleLevel(sampler_Tex${slot}, ${uv}, ${lod})`
188
+ : `tex2Dlod(_Tex${slot}, float4(${uv}, 0.0, ${lod}))`,
189
+ sampleWith: (slot, uv, lod, s) => lod === null
190
+ ? `_Tex${slot}.Sample(${inlineSampler(s)}, ${uv})`
191
+ : `_Tex${slot}.SampleLevel(${inlineSampler(s)}, ${uv}, ${lod})`,
192
+ size: (slot) => `_Tex${slot}_TexelSize.zw`,
193
+ fetch: (slot, pixel) => `_Tex${slot}.Load(int3(${pixel}, 0))`,
169
194
  // Flipped as this frame flipped the uv it wrote at, so a read at a
170
195
  // pixel's own uv is that pixel whichever way the host's target runs.
171
196
  previous: (uv) => `tex2Dlod(_Prev, float4(${uv} * float2(1.0, 1.0 - 2.0 * _FlipY) + float2(0.0, _FlipY), 0.0, 0.0))`,
@@ -175,6 +200,36 @@ function unityTarget(p: Program): BodyTarget {
175
200
  }
176
201
  }
177
202
 
203
+ /**
204
+ * An inline sampler state's name. Unity reads the filter and the wrap from the
205
+ * name itself ("point" or "linear", "clamp", "repeat" or "mirror").
206
+ */
207
+ function inlineSampler(s: TextureSampling): string {
208
+ return `sl_sampler_${s.filter}_${s.wrap}`
209
+ }
210
+
211
+ /**
212
+ * How the program reaches each texture slot, which decides how the shader
213
+ * declares it. `object`: declared a Texture2D, because the program sets its
214
+ * sampler or fetches from it. `plain`: an object sampled with its own
215
+ * settings, which needs Unity's `sampler_` state. `sized`: reads `_TexelSize`,
216
+ * as `textureSize` and a fetch's bounds do.
217
+ */
218
+ function textureUse(p: Program): { object: Set<number>; plain: Set<number>; sized: Set<number> } {
219
+ const fetched = new Set<number>(), sampled = new Set<number>(), sized = new Set<number>()
220
+ for (const ref of reachable(p.nodes, p.result)) {
221
+ const n = p.nodes[ref]!
222
+ if (n.k !== "call") continue
223
+ const slot = Math.round(n.imm?.[0] ?? 0)
224
+ if (n.op === SLOP.SAMPLE || n.op === SLOP.SAMPLE_LOD) sampled.add(slot)
225
+ else if (n.op === SLOP.TEXTURE_SIZE) sized.add(slot)
226
+ else if (n.op === SLOP.SAMPLE_FETCH) { fetched.add(slot); sized.add(slot) }
227
+ }
228
+ const object = new Set(p.textures.filter((t) => samplingOf(t) !== null || fetched.has(t.slot)).map((t) => t.slot))
229
+ const plain = new Set([...sampled].filter((slot) => object.has(slot) && samplingOf(p.textures[slot]!) === null))
230
+ return { object, plain, sized }
231
+ }
232
+
178
233
  /** The straight line body: one local per reachable node, in order. */
179
234
  export function emitFragmentBody(p: Program): string {
180
235
  return emitBody(p, unityTarget(p)).body
package/src/index.ts CHANGED
@@ -18,10 +18,10 @@
18
18
  */
19
19
  export * from "./core"
20
20
  export {
21
- analyze, classify, diagnose, parse, parseUnit, preludeFunctions, tokenize, PRELUDE_SOURCE, SLParseError,
21
+ analyze, classify, classifyLine, diagnose, parse, parseUnit, preludeFunctions, tokenize, PRELUDE_SOURCE, SLParseError,
22
22
  } from "./lang"
23
23
  export type {
24
- Checked, Expr, FuncDecl, ParseOptions, Pos, SLClassifiedToken, SLFix, SLTokenClass, Stmt, Token, Unit,
24
+ Checked, Expr, FuncDecl, ParseOptions, Pos, SLClassifiedToken, SLFix, SLLineState, SLTokenClass, Stmt, Token, Unit,
25
25
  } from "./lang"
26
26
  export { fromGLSL } from "./lang/glsl"
27
27
  export type { FromGLSL } from "./lang/glsl"
package/src/ir.ts CHANGED
@@ -274,10 +274,45 @@ export function intUniformProblem(type: SLType, value: readonly number[]): strin
274
274
  return null
275
275
  }
276
276
 
277
+ /** How a texture's coordinates outside 0 to 1 read: tiled, held at the edge, or tiled mirrored. */
278
+ export type TextureWrap = "repeat" | "clamp" | "mirror"
279
+ /** How a texture is read between texels: the nearest one, or a blend of the four around. */
280
+ export type TextureFilter = "point" | "linear"
281
+
282
+ /** The sampler a program sets for a texture, both halves filled in (`samplingOf`). */
283
+ export interface TextureSampling {
284
+ wrap: TextureWrap
285
+ filter: TextureFilter
286
+ }
287
+
288
+ export const TEXTURE_WRAPS: readonly TextureWrap[] = ["repeat", "clamp", "mirror"]
289
+ export const TEXTURE_FILTERS: readonly TextureFilter[] = ["point", "linear"]
290
+
277
291
  export interface TextureDecl {
278
292
  name: string
279
293
  /** Sampler slot, assigned in declaration order. */
280
294
  slot: number
295
+ /**
296
+ * How the program samples it, as written: `[Wrap(Repeat)]` and
297
+ * `[Filter(Point)]` in a `.sl` file, `sl.texture(name, { wrap, filter })`.
298
+ * Neither set, the texture samples as the texture bound to it is set,
299
+ * which is what every program did before IR 6. Either set, the program
300
+ * owns the sampler, and the half it leaves out is clamp or linear
301
+ * (`samplingOf`). Part of the hash, since the shader differs.
302
+ */
303
+ wrap?: TextureWrap
304
+ filter?: TextureFilter
305
+ }
306
+
307
+ /**
308
+ * The sampler a texture's program sets, or null when it samples as the bound
309
+ * texture is set. A program that names one half gets clamp or linear for the
310
+ * other, on every host: Unity's inline sampler states and WebGPU's samplers
311
+ * have no way to take one half from the texture and the other from the shader.
312
+ */
313
+ export function samplingOf(t: Pick<TextureDecl, "wrap" | "filter">): TextureSampling | null {
314
+ if (t.wrap === undefined && t.filter === undefined) return null
315
+ return { wrap: t.wrap ?? "clamp", filter: t.filter ?? "linear" }
281
316
  }
282
317
 
283
318
  export interface Program {
@@ -308,8 +343,9 @@ export function tooManyUniforms(declared: number, source: "file" | "program"): s
308
343
 
309
344
  export function tooManyTextures(declared: number, source: "file" | "program"): string {
310
345
  return `this ${source} declares ${declared} textures and a program may sample ${TEXTURE_SLOTS}. ` +
311
- `OneJS binds ${TEXTURE_SLOTS} textures to a program in the editor and a native player, so ` +
312
- `this one would sample nothing there while a browser drew it: two pictures from one ${source}.`
346
+ `OneJS binds ${TEXTURE_SLOTS} textures to a program, so this one would have no slot to be ` +
347
+ `bound in; it is refused here rather than drawn blank. Pack two images into one texture's ` +
348
+ `channels, or draw in two passes.`
313
349
  }
314
350
 
315
351
  /**
@@ -323,6 +359,17 @@ export function checkCaps(p: Pick<Program, "uniforms" | "textures">): void {
323
359
  if (p.textures.length > TEXTURE_SLOTS) throw new SLError(tooManyTextures(p.textures.length, "program"))
324
360
  }
325
361
 
362
+ /** What is wrong with a texture's sampler, or null. The one check, for the builder and `fromJSON`. */
363
+ export function samplingProblem(s: { wrap?: unknown; filter?: unknown }): string | null {
364
+ if (s.wrap !== undefined && !TEXTURE_WRAPS.includes(s.wrap as TextureWrap)) {
365
+ return `a wrap is ${TEXTURE_WRAPS.join(", ")}, and this is ${String(s.wrap)}`
366
+ }
367
+ if (s.filter !== undefined && !TEXTURE_FILTERS.includes(s.filter as TextureFilter)) {
368
+ return `a filter is ${TEXTURE_FILTERS.join(" or ")}, and this is ${String(s.filter)}`
369
+ }
370
+ return null
371
+ }
372
+
326
373
  /**
327
374
  * The colour as written behind a colour value: the operand of the `TO_LINEAR`
328
375
  * that reading a hex or a colour uniform applied, or null for a value computed
@@ -446,14 +493,30 @@ export class Builder {
446
493
  return this.add({ k: "uniform", type, slot })
447
494
  }
448
495
 
449
- texture(name: string): number {
496
+ texture(name: string, sampling: Pick<TextureDecl, "wrap" | "filter"> = {}): number {
497
+ const problem = samplingProblem(sampling)
498
+ if (problem !== null) throw new SLError(`texture "${name}": ${problem}`)
450
499
  const existing = this.textures.findIndex((t) => t.name === name)
451
- if (existing >= 0) return this.textures[existing].slot
500
+ if (existing >= 0) {
501
+ // A second use may say nothing about the sampler, or the same thing again.
502
+ const t = this.textures[existing]!
503
+ for (const f of ["wrap", "filter"] as const) {
504
+ if (sampling[f] === undefined) continue
505
+ if (t[f] !== undefined && t[f] !== sampling[f]) {
506
+ throw new SLError(`texture "${name}" is given two different ${f}s, ${t[f]} and ${sampling[f]}`)
507
+ }
508
+ t[f] = sampling[f] as never
509
+ }
510
+ return t.slot
511
+ }
452
512
  if (this.textures.length >= TEXTURE_SLOTS) {
453
513
  throw new SLError(tooManyTextures(this.textures.length + 1, "program"))
454
514
  }
455
515
  const slot = this.textures.length
456
- this.textures.push({ name, slot })
516
+ const decl: TextureDecl = { name, slot }
517
+ if (sampling.wrap !== undefined) decl.wrap = sampling.wrap
518
+ if (sampling.filter !== undefined) decl.filter = sampling.filter
519
+ this.textures.push(decl)
457
520
  return slot
458
521
  }
459
522
  }
@@ -461,9 +524,9 @@ export class Builder {
461
524
  /**
462
525
  * Nodes the result actually depends on, in order.
463
526
  *
464
- * Dead nodes are dropped rather than encoded. An author can produce them easily
527
+ * Dead nodes are dropped rather than emitted. An author can produce them easily
465
528
  * by computing something and not using it, and the hash already ignores them, so
466
- * encoding them would make the buffer disagree with its own hash about what the
529
+ * emitting them would make a shader disagree with its own hash about what the
467
530
  * program is.
468
531
  */
469
532
  export function reachable(nodes: SLNode[], result: NodeRef): NodeRef[] {
@@ -519,6 +582,42 @@ export function readsOf(p: Program): SLReads {
519
582
  return out
520
583
  }
521
584
 
585
+ /** Ops whose result is a whole number whatever they are given. */
586
+ const ALWAYS_WHOLE = new Set<number>([SLOP.FLOOR, SLOP.CEIL, SLOP.ROUND, SLOP.SIGN])
587
+ /** Ops whose result is a whole number when every operand they read is one. */
588
+ const KEEPS_WHOLE = new Set<number>([
589
+ SLOP.CAST, SLOP.ADD, SLOP.SUB, SLOP.MUL, SLOP.NEG, SLOP.ABS, SLOP.MIN, SLOP.MAX, SLOP.CLAMP, SLOP.SATURATE,
590
+ ])
591
+
592
+ /**
593
+ * Whether a value is a whole number in every component on every pixel, as far
594
+ * as its node says. An int, uint or bool is; so are a constant written whole,
595
+ * `floor`, `ceil`, `round` and `sign`, and sums, products, minimums and the
596
+ * like of whole numbers (a float32 sum or product of whole numbers is whole).
597
+ * Anything else is not known to be, a uniform float included.
598
+ *
599
+ * The emitters read it for `tex2Dlod`: a level known whole reads that level
600
+ * with one sample, and any other blends the level below and the level above by
601
+ * the fraction, so every backend draws a fraction the same way whatever the
602
+ * texture's filter between levels.
603
+ */
604
+ export function isWhole(nodes: readonly SLNode[], ref: NodeRef, seen = new Map<NodeRef, boolean>()): boolean {
605
+ const known = seen.get(ref)
606
+ if (known !== undefined) return known
607
+ const n = valueAt(nodes, ref)
608
+ let whole = false
609
+ if (n.kind !== undefined) whole = true
610
+ else if (n.k === "const") whole = n.v.every(Number.isInteger)
611
+ else if (n.k === "swizzle") whole = isWhole(nodes, n.src, seen)
612
+ else if (n.k === "call") {
613
+ if (ALWAYS_WHOLE.has(n.op)) whole = true
614
+ else if (KEEPS_WHOLE.has(n.op)) whole = n.args.every((a) => isWhole(nodes, a, seen))
615
+ else if (n.op === SLOP.CHOOSE) whole = isWhole(nodes, n.args[1]!, seen) && isWhole(nodes, n.args[2]!, seen)
616
+ }
617
+ seen.set(ref, whole)
618
+ return whole
619
+ }
620
+
522
621
  /** Structural key for hash consing. Order matters and is fixed by the node shape. */
523
622
  function keyOf(n: SLNode): string {
524
623
  const kind = "kind" in n && n.kind !== undefined ? `@${n.kind}` : ""
@@ -651,9 +750,15 @@ export function hashProgram(nodes: SLNode[], result: NodeRef, uniforms: UniformD
651
750
  // includes the library's text when it compiles, and none caches a compiled
652
751
  // program by hash across a library update, so the new text reaches every
653
752
  // shader without a bump.
654
- const parts: string[] = [`v${SL_HASH_VERSION}:${programVersion(nodes, result)}`, of(result)]
753
+ const parts: string[] = [`v${SL_HASH_VERSION}:${programVersion(nodes, result, textures)}`, of(result)]
655
754
  for (const u of uniforms) parts.push(`U:${u.name}:${u.type}:${u.value.map(fixed).join(",")}`)
656
- for (const t of textures) parts.push(`T:${t.name}:${t.slot}`)
755
+ // A texture's sampler as the shader has it, so `[Wrap(Clamp)]` and
756
+ // `[Filter(Linear)]` share a hash, and a texture with neither has the
757
+ // part it had before IR 6.
758
+ for (const t of textures) {
759
+ const s = samplingOf(t)
760
+ parts.push(`T:${t.name}:${t.slot}${s === null ? "" : `:${s.wrap}:${s.filter}`}`)
761
+ }
657
762
  return fnv1a(parts.join("|"))
658
763
  }
659
764
 
@@ -715,8 +820,8 @@ export function controlOf(c: UniformControl): UniformControl {
715
820
  * int constant a fold used up, say), and they draw nothing. `toJSON` writes
716
821
  * only the live ones, so an older reader never meets a dead newer node.
717
822
  */
718
- export function programVersion(nodes: readonly SLNode[], result: NodeRef): number {
719
- let v = 2
823
+ export function programVersion(nodes: readonly SLNode[], result: NodeRef, textures: readonly TextureDecl[] = []): number {
824
+ let v = textures.some((t) => samplingOf(t) !== null) ? 6 : 2
720
825
  for (const ref of reachable(nodes as SLNode[], result)) v = Math.max(v, nodeVersion(nodes[ref]!))
721
826
  return v
722
827
  }
@@ -738,6 +843,9 @@ function nodeVersion(n: SLNode): number {
738
843
  if (n.k !== "call") return 2
739
844
  if (IR4_OPS.has(n.op)) return 4
740
845
  switch (n.op) {
846
+ case SLOP.TEXTURE_SIZE:
847
+ case SLOP.SAMPLE_FETCH:
848
+ return 6
741
849
  case SLOP.SAMPLE_PREVIOUS: return 5
742
850
  case SLOP.SAMPLE_LOD: return 3
743
851
  case SLOP.SDF:
@@ -761,6 +869,8 @@ function nodeVersion(n: SLNode): number {
761
869
  * one immediate
762
870
  * SAMPLE_PREVIOUS a float4 at a float2, and no immediates: there is one
763
871
  * previous frame, so there is no slot to name
872
+ * TEXTURE_SIZE a float2 of nothing, the slot the one immediate
873
+ * SAMPLE_FETCH a float4 at a float2 pixel, the slot the one immediate
764
874
  */
765
875
  export function formProblem(n: Extract<SLNode, { k: "call" }>, nodes: readonly SLNode[]): string | null {
766
876
  const widths = n.args.map((a) => { const v = nodes[a]!; return v.k === "if" || v.k === "loop" ? "control" : v.type }).join(",")
@@ -778,6 +888,12 @@ export function formProblem(n: Extract<SLNode, { k: "call" }>, nodes: readonly S
778
888
  case SLOP.SAMPLE_PREVIOUS:
779
889
  if (widths === "2" && n.type === 4 && n.kind === undefined && (n.imm?.length ?? 0) === 0) return null
780
890
  return "the previous frame is a float4 read at a float2"
891
+ case SLOP.TEXTURE_SIZE:
892
+ if (widths === "" && n.type === 2 && n.kind === undefined && n.imm?.length === 1) return null
893
+ return "a texture's size is a float2, its slot the one immediate"
894
+ case SLOP.SAMPLE_FETCH:
895
+ if (widths === "2" && n.type === 4 && n.kind === undefined && n.imm?.length === 1) return null
896
+ return "a fetch is a float4 read at a float2 pixel, its slot the one immediate"
781
897
  default:
782
898
  return null
783
899
  }
@@ -819,8 +935,9 @@ export const SL_HASH_VERSION = 2
819
935
  * operands, where they were immediates
820
936
  * 4 control flow (if, loop, their params and projs), ints, uints and bools
821
937
  * 5 the previous frame (SAMPLE_PREVIOUS), and the frame and deltaTime inputs
938
+ * 6 TEXTURE_SIZE and SAMPLE_FETCH, and a texture's sampler (`wrap`, `filter`)
822
939
  */
823
- export const SL_IR_VERSION = 5
940
+ export const SL_IR_VERSION = 6
824
941
 
825
942
  function fnv1a(s: string): string {
826
943
  let h = 0x811c9dc5
@@ -41,9 +41,14 @@ player. So parity is the test to keep green, not the parser's unit tests.
41
41
  | `ast.ts` | The node shapes, all of them, which is not many |
42
42
  | `parser.ts` | Pratt parser: shape only, HLSL precedence |
43
43
  | `check.ts` | Declarations, names, statement shape, budgets, recursion |
44
- | `lower.ts` | AST to IR through the EDSL: SSA, unrolling, `select`, inlining |
44
+ | `lower.ts` | AST to IR through the EDSL: SSA, unrolling, branches and loops, inlining |
45
45
  | `builtins.ts` | What each name does, keyed by the spelling `ops.ts` gives it |
46
- | `prelude.ts` | The standard library, written in the language |
46
+ | `prelude.ts`, `prelude-source.ts` | The standard library, written in the language |
47
+ | `attributes.ts` | A uniform's `[Range]`, `[Toggle]` and the rest, read into its control |
48
+ | `words.ts` | The keywords and type names, shared by the parser and `classify` |
49
+ | `classify.ts` | Every token with its class, for an editor's highlighting |
50
+ | `glsl.ts` | `fromGLSL`: a pasted GLSL shader as a `.sl` file |
51
+ | `index.ts` | This folder's exports |
47
52
  | `onejs-unity`'s `src/esbuild/sl.mjs` | The loader: `import plasma from "./plasma.sl"` |
48
53
  | `../index.ts` and `../core.ts` | The package with the parser, and without it. See below |
49
54
 
@@ -68,9 +73,11 @@ It writes two things beside the code:
68
73
  recording stays for EDSL programs and for a build older than the loader; the
69
74
  editor reads every `*.sl.json` it can find.
70
75
 
71
- An empty manifest is written only over one that is already there. Deleting the
72
- last `.sl` file has to stop its shaders being generated, and a project that has
73
- never had one should not find a new file beside its bundle.
76
+ With no `.sl` files, an empty manifest is written over one that is already
77
+ there, since deleting the last `.sl` file has to stop its shaders being
78
+ generated, and beside a bundle carrying `onejs-sl`, which has to name its hash
79
+ scheme. A project that uses no shader language finds no new file beside its
80
+ bundle.
74
81
 
75
82
  ### Why the parser is compiled rather than imported
76
83
 
@@ -110,7 +117,8 @@ here is the one nothing else uses.
110
117
  **A scalar on the left of an operator broadcasts through a swizzle.** `uv * 8`
111
118
  is `uv.mul(8)`, one constant node of two components. `8 * uv` is
112
119
  `sl.float(8).mul(uv)`, a one component constant and a broadcast. Same picture,
113
- different graph, different hash. Write the vector on the left when it matters.
120
+ different graph, and since 0.4.0 (`SL_HASH_VERSION` 2) the same hash. The
121
+ vector on the left saves a node.
114
122
 
115
123
  **Literal arithmetic folds; a builtin call never does.** `2 * 3 + 1` is the
116
124
  constant 7 before the IR sees it. `sin(0.5)` is two instructions, because
@@ -161,8 +169,8 @@ recorded gap, not an oversight, and each has its own error message rather than
161
169
  | `rgb2hsv` | The opcode is numbered and **no backend implements it**; every emitter throws on it. |
162
170
  | `fbm`'s simplex base | `sl.fbm(p, octaves, "simplex")` picks the base with a string, and the language has no strings. `fbm(p, octaves)` is the value base, and `turbulence` and `ridged` are the simplex family. |
163
171
 
164
- A `.sl` file and the EDSL are held to the same caps, `UNIFORM_SLOTS` (16) and
165
- `TEXTURE_SLOTS` (4), reported at the declaration in one wording (`ir.ts`'s
172
+ A `.sl` file and the EDSL are held to the same caps, `UNIFORM_SLOTS` (64) and
173
+ `TEXTURE_SLOTS` (8), reported at the declaration in one wording (`ir.ts`'s
166
174
  `tooManyUniforms` and `tooManyTextures`).
167
175
 
168
176
  ## Attributes
@@ -185,6 +193,16 @@ What they produce is metadata on the uniform (`range`, `toggle`, `options`,
185
193
  `header`, `label`, `hide`), which the hash never reads. `[Color]` is the
186
194
  exception, because it changes the reads: it is the same as a hex default.
187
195
 
196
+ A texture takes two of its own, which are not metadata: `[Wrap(Repeat)]`,
197
+ `[Wrap(Mirror)]` or `[Wrap(Clamp)]`, and `[Filter(Point)]` or `[Filter(Linear)]`,
198
+ read by `readTextureAttributes`. They change the shader (a sampler the program
199
+ owns rather than the bound texture's), so they are in the hash and make a
200
+ program IR 6; the half left out is clamp or linear (`samplingOf`). Unity's
201
+ `Bilinear` and `WrapMode` are offered as fixes, and `Trilinear` and
202
+ `MirrorOnce` are refused with why: no backend's per program sampler blends
203
+ levels, and WebGPU has no mirror once. A uniform's attribute on a texture, or
204
+ a texture's on a uniform, says which one it belongs on.
205
+
188
206
  ## Errors
189
207
 
190
208
  Every one carries file, line, column, offset and length, and `text`, the
@@ -201,7 +219,7 @@ Where the fix is certain, the error carries it as `fix`: a title and the text
201
219
  for the marked range, one click in an editor. A GLSL spelling that means the
202
220
  same in HLSL (`mix`, `fract`, `vec3`, `gl_FragCoord`, `iTime`, two argument
203
221
  `atan`) and a "did you mean" both have one. `mod` does not, since GLSL's floors
204
- and `%` truncates, and neither does `int`, since float would change what
222
+ and `%` truncates, and neither does `ivec2`, since `float2` would change what
205
223
  dividing it does. That is Decision 1 kept as `Specs/SL_NEXT.md` 5 A has it:
206
224
  one spelling, and every hint a fix.
207
225
 
@@ -229,6 +247,13 @@ on the parser's own scanner and word lists, so the highlighting is the parser's.
229
247
  It never throws, since it runs on every keystroke. It classifies by spelling,
230
248
  not scope: a local named `circle` still reads as a builtin where it is used.
231
249
 
250
+ `classifyLine(line, state)` is the same for an editor that colours a line at a
251
+ time, as Monaco does: the line's tokens and the state the next line starts
252
+ from (inside a block comment or not, and the two tokens before it). Every line
253
+ of a file classified that way gives what `classify` gives the whole file, a
254
+ block comment over several lines coming back as one piece per line; the corpus
255
+ holds it to that.
256
+
232
257
  `SL_KEYWORDS` and `SL_TYPES` (`words.ts`) are the lists the parser reads, and a
233
258
  name may be neither. `onejs-sl/tables` carries them, and `ATTRIBUTE_NAMES`, with
234
259
  a one line description of every builtin, input, attribute and prelude function; a prelude function's is the
@@ -249,7 +274,9 @@ where they were, so the result reads as the shader it came from. What it does:
249
274
  the inputs. So do the calls that need an
250
275
  expression: `mod` floors (`a - b * floor(a / b)`, never `%`, which
251
276
  truncates), and `atan`, `inversesqrt`, `radians`, `degrees`, `exp2` and
252
- `log2` become what they compute.
277
+ `log2` become what they compute. `texelFetch(t, p, 0)` is
278
+ `tex2Dfetch(t, p)` and `textureSize(t, 0)` is `textureSize(t)`; another
279
+ level gets a note, since both read the full size texture.
253
280
  - `mainImage` becomes `float4 main()`, with its colour parameter as a local it
254
281
  returns; `void main()` writing `gl_FragColor` or a WebGL2 `out vec4` does the
255
282
  same. Shadertoy's `uv = fragCoord / iResolution.xy` line is dropped, since
@@ -268,8 +295,10 @@ where they were, so the result reads as the shader it came from. What it does:
268
295
  the notes point at `rotate()`. A file with a `rotate` of its own is left
269
296
  alone, since the calls would reach it. `#define NAME value`
270
297
  becomes a const; any other directive is kept as a comment, with a note.
271
- - A name the language already means something by gets a `_` suffix, and an
272
- `int` becomes a float (a for-loop counter stays an int), each with a note.
298
+ - A name the language already means something by gets a `_` suffix, with a
299
+ note. `int`, `uint` and `bool` are kept, since they are types here too; their
300
+ vectors (`ivec2`, `uvec3`, `bvec4` and the rest) become float vectors, with a
301
+ note saying what changed.
273
302
  - What has no counterpart (`iFrameRate`, `texelFetch`, `dFdx`, a function
274
303
  returning nothing) is left in place with a note, for `errors` to point at.
275
304
 
package/src/lang/ast.ts CHANGED
@@ -99,7 +99,10 @@ export type AttributeArg =
99
99
  | { k: "str"; text: string; pos: Pos; length: number }
100
100
  | { k: "ident"; name: string; pos: Pos; length: number }
101
101
 
102
- /** `[Range(0, 2)]` before a uniform: Unity's spelling of what its control is. */
102
+ /**
103
+ * `[Range(0, 2)]` before a uniform, Unity's spelling of what its control is,
104
+ * or `[Wrap(Repeat)]` before a texture, of how it is sampled.
105
+ */
103
106
  export interface Attribute {
104
107
  name: string
105
108
  args: AttributeArg[]
@@ -120,6 +123,8 @@ export interface UniformDecl {
120
123
 
121
124
  export interface TextureDecl {
122
125
  name: string
126
+ /** `[Wrap(...)]` and `[Filter(...)]`, in the order written. */
127
+ attrs: Attribute[]
123
128
  pos: Pos
124
129
  }
125
130