onejs-sl 0.8.0 → 0.9.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/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
  }
@@ -687,9 +750,15 @@ export function hashProgram(nodes: SLNode[], result: NodeRef, uniforms: UniformD
687
750
  // includes the library's text when it compiles, and none caches a compiled
688
751
  // program by hash across a library update, so the new text reaches every
689
752
  // shader without a bump.
690
- 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)]
691
754
  for (const u of uniforms) parts.push(`U:${u.name}:${u.type}:${u.value.map(fixed).join(",")}`)
692
- 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
+ }
693
762
  return fnv1a(parts.join("|"))
694
763
  }
695
764
 
@@ -751,8 +820,8 @@ export function controlOf(c: UniformControl): UniformControl {
751
820
  * int constant a fold used up, say), and they draw nothing. `toJSON` writes
752
821
  * only the live ones, so an older reader never meets a dead newer node.
753
822
  */
754
- export function programVersion(nodes: readonly SLNode[], result: NodeRef): number {
755
- 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
756
825
  for (const ref of reachable(nodes as SLNode[], result)) v = Math.max(v, nodeVersion(nodes[ref]!))
757
826
  return v
758
827
  }
@@ -774,6 +843,9 @@ function nodeVersion(n: SLNode): number {
774
843
  if (n.k !== "call") return 2
775
844
  if (IR4_OPS.has(n.op)) return 4
776
845
  switch (n.op) {
846
+ case SLOP.TEXTURE_SIZE:
847
+ case SLOP.SAMPLE_FETCH:
848
+ return 6
777
849
  case SLOP.SAMPLE_PREVIOUS: return 5
778
850
  case SLOP.SAMPLE_LOD: return 3
779
851
  case SLOP.SDF:
@@ -797,6 +869,8 @@ function nodeVersion(n: SLNode): number {
797
869
  * one immediate
798
870
  * SAMPLE_PREVIOUS a float4 at a float2, and no immediates: there is one
799
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
800
874
  */
801
875
  export function formProblem(n: Extract<SLNode, { k: "call" }>, nodes: readonly SLNode[]): string | null {
802
876
  const widths = n.args.map((a) => { const v = nodes[a]!; return v.k === "if" || v.k === "loop" ? "control" : v.type }).join(",")
@@ -814,6 +888,12 @@ export function formProblem(n: Extract<SLNode, { k: "call" }>, nodes: readonly S
814
888
  case SLOP.SAMPLE_PREVIOUS:
815
889
  if (widths === "2" && n.type === 4 && n.kind === undefined && (n.imm?.length ?? 0) === 0) return null
816
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"
817
897
  default:
818
898
  return null
819
899
  }
@@ -855,8 +935,9 @@ export const SL_HASH_VERSION = 2
855
935
  * operands, where they were immediates
856
936
  * 4 control flow (if, loop, their params and projs), ints, uints and bools
857
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`)
858
939
  */
859
- export const SL_IR_VERSION = 5
940
+ export const SL_IR_VERSION = 6
860
941
 
861
942
  function fnv1a(s: string): string {
862
943
  let h = 0x811c9dc5
@@ -169,8 +169,8 @@ recorded gap, not an oversight, and each has its own error message rather than
169
169
  | `rgb2hsv` | The opcode is numbered and **no backend implements it**; every emitter throws on it. |
170
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. |
171
171
 
172
- A `.sl` file and the EDSL are held to the same caps, `UNIFORM_SLOTS` (16) and
173
- `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
174
174
  `tooManyUniforms` and `tooManyTextures`).
175
175
 
176
176
  ## Attributes
@@ -193,6 +193,16 @@ What they produce is metadata on the uniform (`range`, `toggle`, `options`,
193
193
  `header`, `label`, `hide`), which the hash never reads. `[Color]` is the
194
194
  exception, because it changes the reads: it is the same as a hex default.
195
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
+
196
206
  ## Errors
197
207
 
198
208
  Every one carries file, line, column, offset and length, and `text`, the
@@ -237,6 +247,13 @@ on the parser's own scanner and word lists, so the highlighting is the parser's.
237
247
  It never throws, since it runs on every keystroke. It classifies by spelling,
238
248
  not scope: a local named `circle` still reads as a builtin where it is used.
239
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
+
240
257
  `SL_KEYWORDS` and `SL_TYPES` (`words.ts`) are the lists the parser reads, and a
241
258
  name may be neither. `onejs-sl/tables` carries them, and `ATTRIBUTE_NAMES`, with
242
259
  a one line description of every builtin, input, attribute and prelude function; a prelude function's is the
@@ -257,7 +274,9 @@ where they were, so the result reads as the shader it came from. What it does:
257
274
  the inputs. So do the calls that need an
258
275
  expression: `mod` floors (`a - b * floor(a / b)`, never `%`, which
259
276
  truncates), and `atan`, `inversesqrt`, `radians`, `degrees`, `exp2` and
260
- `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.
261
280
  - `mainImage` becomes `float4 main()`, with its colour parameter as a local it
262
281
  returns; `void main()` writing `gl_FragColor` or a WebGL2 `out vec4` does the
263
282
  same. Shadertoy's `uv = fragCoord / iResolution.xy` line is dropped, since
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
 
@@ -10,8 +10,8 @@
10
10
  * shares.
11
11
  */
12
12
 
13
- import type { UniformControl } from "../ir"
14
- import { TYPE_WIDTH, type Attribute, type AttributeArg, type UniformDecl } from "./ast"
13
+ import { TEXTURE_FILTERS, TEXTURE_WRAPS, type TextureFilter, type TextureWrap, type UniformControl } from "../ir"
14
+ import { TYPE_WIDTH, type Attribute, type AttributeArg, type TextureDecl, type UniformDecl } from "./ast"
15
15
  import type { Pos, SLFix } from "./lexer"
16
16
 
17
17
  export interface AttributeProblem {
@@ -42,6 +42,75 @@ const ALIASES: Record<string, AttributeName> = {
42
42
 
43
43
  const LIST = "Range, Toggle, Enum, Header, Label, Color and Hide"
44
44
 
45
+ /** A texture's attributes (`Specs/SL_NEXT.md` 8): how the program samples it. */
46
+ export const TEXTURE_ATTRIBUTE_NAMES = ["Wrap", "Filter"] as const
47
+ export type TextureAttributeName = (typeof TEXTURE_ATTRIBUTE_NAMES)[number]
48
+
49
+ /** What each texture attribute takes, as written, from Unity's TextureWrapMode and FilterMode where they agree. */
50
+ const TEXTURE_VALUES: Record<TextureAttributeName, Record<string, TextureWrap | TextureFilter>> = {
51
+ Wrap: Object.fromEntries(TEXTURE_WRAPS.map((w) => [w[0]!.toUpperCase() + w.slice(1), w])),
52
+ Filter: Object.fromEntries(TEXTURE_FILTERS.map((f) => [f[0]!.toUpperCase() + f.slice(1), f])),
53
+ }
54
+
55
+ /** Unity's spellings that mean one of ours, and the ones no host can match, with why. */
56
+ const TEXTURE_VALUE_ALIASES: Record<string, string> = { Bilinear: "Linear", Nearest: "Point" }
57
+ const TEXTURE_VALUE_REFUSED: Record<string, string> = {
58
+ Trilinear: "a program samples one mip level at a time, so there is no blend between levels to ask for; " +
59
+ "write [Filter(Linear)], and tex2Dlod with a fractional level blends two levels",
60
+ MirrorOnce: "WebGPU has no mirror once, so a browser could not draw it; write [Wrap(Mirror)] or [Wrap(Clamp)]",
61
+ }
62
+
63
+ export interface ReadTextureAttributes {
64
+ /** What could be read. Neither set, the texture samples as the bound texture is set. */
65
+ sampling: { wrap?: TextureWrap; filter?: TextureFilter }
66
+ problems: AttributeProblem[]
67
+ }
68
+
69
+ /** `[Wrap(Repeat)]` and `[Filter(Point)]` on a `texture2D`, read into its sampler. */
70
+ export function readTextureAttributes(t: TextureDecl): ReadTextureAttributes {
71
+ const out: ReadTextureAttributes = { sampling: {}, problems: [] }
72
+ const seen = new Set<string>()
73
+ const problem = (at: Attribute | AttributeArg, message: string, fix?: SLFix) => {
74
+ out.problems.push({ message, pos: at.pos, length: at.length, ...(fix === undefined ? {} : { fix }) })
75
+ }
76
+ for (const a of t.attrs) {
77
+ const name = (TEXTURE_ATTRIBUTE_NAMES as readonly string[]).includes(a.name) ? a.name as TextureAttributeName
78
+ : a.name === "WrapMode" ? "Wrap" : a.name === "FilterMode" ? "Filter" : null
79
+ if (name === null) {
80
+ const uniform = (ATTRIBUTE_NAMES as readonly string[]).includes(a.name) || a.name in ALIASES
81
+ problem(a, uniform
82
+ ? `[${a.name}] is for a uniform; a texture takes [Wrap(Repeat)], [Wrap(Mirror)], [Wrap(Clamp)] and [Filter(Point)], [Filter(Linear)]`
83
+ : `"${a.name}" is not an attribute of a texture; a texture takes Wrap and Filter`)
84
+ continue
85
+ }
86
+ if (name !== a.name) {
87
+ problem(a, `"${a.name}" is ${name} here: [${name}(...)]`, { title: `Replace ${a.name} with ${name}`, replacement: name })
88
+ continue
89
+ }
90
+ if (seen.has(name)) { problem(a, `this texture already has [${name}]`); continue }
91
+ seen.add(name)
92
+ const values = TEXTURE_VALUES[name]
93
+ const listed = Object.keys(values).join(", ")
94
+ const arg = a.args.length === 1 && a.args[0]!.k === "ident" ? a.args[0]! as Extract<AttributeArg, { k: "ident" }> : null
95
+ if (arg === null) {
96
+ problem(a.args[0] ?? a, `[${name}] takes one of ${listed}, as in [${name}(${Object.keys(values)[0]})]`)
97
+ continue
98
+ }
99
+ const v = values[arg.name]
100
+ if (v !== undefined) {
101
+ if (name === "Wrap") out.sampling.wrap = v as TextureWrap
102
+ else out.sampling.filter = v as TextureFilter
103
+ continue
104
+ }
105
+ const refused = TEXTURE_VALUE_REFUSED[arg.name]
106
+ if (refused !== undefined) { problem(arg, `${name}(${arg.name}) is not offered: ${refused}`); continue }
107
+ const to = TEXTURE_VALUE_ALIASES[arg.name] ?? Object.keys(values).find((k) => k.toLowerCase() === arg.name.toLowerCase())
108
+ problem(arg, `${name} takes ${listed}, and this is ${arg.name}${to === undefined ? "" : `. Did you mean ${to}?`}`,
109
+ to === undefined ? undefined : { title: `Replace ${arg.name} with ${to}`, replacement: to })
110
+ }
111
+ return out
112
+ }
113
+
45
114
  export function readAttributes(u: UniformDecl): ReadAttributes {
46
115
  const out: ReadAttributes = { control: {}, colour: false, from: {}, problems: [] }
47
116
  const seen = new Set<string>()
@@ -62,6 +131,10 @@ export function readAttributes(u: UniformDecl): ReadAttributes {
62
131
 
63
132
  for (const a of u.attrs) {
64
133
  const known = (ATTRIBUTE_NAMES as readonly string[]).includes(a.name)
134
+ if (!known && ((TEXTURE_ATTRIBUTE_NAMES as readonly string[]).includes(a.name) || a.name === "WrapMode" || a.name === "FilterMode")) {
135
+ problem(a, `[${a.name}] is for a texture, as in [${a.name.replace("Mode", "")}(${a.name.startsWith("Wrap") ? "Repeat" : "Point"})] texture2D grain;`)
136
+ continue
137
+ }
65
138
  if (!known) {
66
139
  const to = ALIASES[a.name] ?? ATTRIBUTE_NAMES.find((n) => n.toLowerCase() === a.name.toLowerCase())
67
140
  const hint = a.name === "IntRange" ? "; a whole number slider is [Range(min, max, 1)]" : ""
@@ -137,6 +137,8 @@ function point(fn: string, v: Num): Val {
137
137
 
138
138
  special(SLOP.SAMPLE, 2, 2)
139
139
  special(SLOP.SAMPLE_LOD, 3, 3)
140
+ special(SLOP.TEXTURE_SIZE, 1, 1)
141
+ special(SLOP.SAMPLE_FETCH, 2, 2)
140
142
  special(SLOP.SDF, 1, 7)
141
143
  special(SLOP.RAMP, 3, 64)
142
144
 
package/src/lang/check.ts CHANGED
@@ -34,7 +34,7 @@ import { SL_GLSL_HINT } from "../ops"
34
34
  import { BUILTINS, NOT_YET } from "./builtins"
35
35
  import { SL_SDF_SHAPES } from "../shapes"
36
36
  import { TYPE_WIDTH, type Expr, type FuncDecl, type Stmt, type Unit } from "./ast"
37
- import { readAttributes } from "./attributes"
37
+ import { readAttributes, readTextureAttributes } from "./attributes"
38
38
  import { SLParseError, type Pos, type SLFix } from "./lexer"
39
39
 
40
40
  const INPUT_NAMES = new Set(Object.keys(INPUTS))
@@ -68,7 +68,7 @@ const GLSL_RENAMES = new Set([
68
68
  ])
69
69
 
70
70
  /** The builtins whose first argument is a texture, which is not a value anywhere else. */
71
- const SAMPLES = new Set(["tex2D", "tex2Dlod"])
71
+ const SAMPLES = new Set(["tex2D", "tex2Dlod", "textureSize", "tex2Dfetch"])
72
72
 
73
73
  const rename = (from: string, to: string): SLFix => ({ title: `Replace ${from} with ${to}`, replacement: to })
74
74
 
@@ -188,6 +188,7 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
188
188
  const clash = valueClash(t.name)
189
189
  if (clash !== null) fail(`"${t.name}" already names ${clash}`, t.pos, t.name.length)
190
190
  })
191
+ for (const p of readTextureAttributes(t).problems) attempt(() => fail(p.message, p.pos, p.length, p.fix))
191
192
  if (!textures.has(t.name)) textures.set(t.name, t)
192
193
  }
193
194
  attempt(() => {
@@ -512,7 +513,7 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
512
513
  if (textures.has(e.name)) {
513
514
  fail(
514
515
  `"${e.name}" is a texture, and a texture is only ever the first argument of ` +
515
- `tex2D or tex2Dlod. Write \`tex2D(${e.name}, uv)\``,
516
+ `tex2D, tex2Dlod, tex2Dfetch or textureSize. Write \`tex2D(${e.name}, uv)\``,
516
517
  e.pos, e.name.length,
517
518
  )
518
519
  }
@@ -636,11 +637,18 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
636
637
  e.pos,
637
638
  )
638
639
  }
640
+ if (previous && n === "textureSize") {
641
+ fail("previous is the size this program draws at, which is `resolution`", e.pos)
642
+ }
643
+ if (previous && n === "tex2Dfetch") {
644
+ fail("previous is read at a uv: `tex2D(previous, uv)`, or `tex2D(previous, (pixel + 0.5) / resolution)` for a pixel", e.pos)
645
+ }
639
646
  if (!previous && (tex === undefined || tex.k !== "ident" || !textures.has(tex.name))) {
640
- const lod = n === "tex2Dlod" ? ", 0" : ""
647
+ const rest = n === "tex2Dlod" ? "uv, 0" : n === "textureSize" ? "" : n === "tex2Dfetch" ? "pixel" : "uv"
648
+ const verb = n === "textureSize" ? "measures" : n === "tex2Dfetch" ? "reads" : "samples"
641
649
  fail(
642
- `${n} samples a texture declared in this file, as in \`texture2D grain;\` ` +
643
- `then \`${n}(grain, uv${lod})\``,
650
+ `${n} ${verb} a texture declared in this file, as in \`texture2D grain;\` ` +
651
+ `then \`${n}(grain${rest === "" ? "" : ", " + rest})\``,
644
652
  (tex ?? e).pos,
645
653
  )
646
654
  }
@@ -46,11 +46,54 @@ const INPUT_NAMES: ReadonlySet<string> = new Set([...Object.keys(SOURCE_INPUTS),
46
46
 
47
47
  /** The tokens of `source`, comments included, in order, each with its class. */
48
48
  export function classify(source: string): SLClassifiedToken[] {
49
+ return run(source, "", "").tokens
50
+ }
51
+
52
+ /**
53
+ * Where a line starts, for `classifyLine`: what the lines before it leave
54
+ * behind that changes how it reads.
55
+ */
56
+ export interface SLLineState {
57
+ /** The line before ended inside a block comment. */
58
+ readonly comment: boolean
59
+ /** The last two tokens before the line that were not comments, which decide what a name after a dot or a bracket is. */
60
+ readonly before: string
61
+ readonly beforeThat: string
62
+ }
63
+
64
+ const FIRST_LINE: SLLineState = { comment: false, before: "", beforeThat: "" }
65
+
66
+ /**
67
+ * One line of a file, for an editor that colours a line at a time, as Monaco
68
+ * does: its tokens, with offsets within the line, and the state the next line
69
+ * starts from. Classifying every line of a file this way, each from the state
70
+ * the one before ended in, gives exactly what `classify` gives the whole file,
71
+ * a block comment over several lines coming back as one piece per line.
72
+ */
73
+ export function classifyLine(line: string, state: SLLineState = FIRST_LINE): { tokens: SLClassifiedToken[]; state: SLLineState } {
74
+ // A line that starts inside a block comment is read with the comment's
75
+ // opening put back in front, so the lexer decides where it closes, and
76
+ // those two characters are taken off the tokens again.
77
+ const shift = state.comment ? 2 : 0
78
+ const read = run(state.comment ? "/*" + line : line, state.before, state.beforeThat)
79
+ const last = read.tokens[read.tokens.length - 1]
80
+ const comment = last !== undefined && last.kind === "comment" && last.text.startsWith("/*")
81
+ && !(last.text.length >= 4 && last.text.endsWith("*/"))
82
+ const tokens: SLClassifiedToken[] = []
83
+ for (const t of read.tokens) {
84
+ if (shift === 0) { tokens.push(t); continue }
85
+ if (t.offset === 0) {
86
+ if (t.length > shift) tokens.push({ ...t, text: t.text.slice(shift), length: t.length - shift })
87
+ } else {
88
+ tokens.push({ ...t, col: t.col - shift, offset: t.offset - shift })
89
+ }
90
+ }
91
+ return { tokens, state: { comment, before: read.before, beforeThat: read.beforeThat } }
92
+ }
93
+
94
+ function run(source: string, before: string, beforeThat: string): { tokens: SLClassifiedToken[]; before: string; beforeThat: string } {
49
95
  const tokens = lex(source, "", true)
50
96
  const out: SLClassifiedToken[] = []
51
- // The last token that was not a comment, which decides what a name after a dot is.
52
- let before = ""
53
- let beforeThat = ""
54
97
  for (const t of tokens) {
55
98
  if (t.kind === "eof") break
56
99
  let kind: SLTokenClass
@@ -68,7 +111,8 @@ export function classify(source: string): SLClassifiedToken[] {
68
111
  kind = t.kind
69
112
  }
70
113
  out.push({ kind, text: t.text, line: t.line, col: t.col, offset: t.offset, length: t.length })
114
+ // The last token that was not a comment, which decides what a name after a dot is.
71
115
  if (t.kind !== "comment") { beforeThat = before; before = t.text }
72
116
  }
73
- return out
117
+ return { tokens: out, before, beforeThat }
74
118
  }
package/src/lang/glsl.ts CHANGED
@@ -98,10 +98,8 @@ const MISSING: Record<string, string> = {
98
98
  iFrameRate: "there is no frame rate",
99
99
  iDate: "there is no date",
100
100
  iChannelTime: "a texture has no time of its own",
101
- iChannelResolution: "a texture's size cannot be read yet",
101
+ iChannelResolution: "write textureSize(iChannel0) for a channel's width and height",
102
102
  iSampleRate: "there is no audio",
103
- texelFetch: "a texture cannot be read without filtering yet",
104
- textureSize: "a texture's size cannot be read yet",
105
103
  dFdx: "there are no derivatives", dFdy: "there are no derivatives", fwidth: "there are no derivatives",
106
104
  }
107
105
 
@@ -490,6 +488,8 @@ export function fromGLSL(glsl: string, options: { file?: string } = {}): FromGLS
490
488
  // ---------- the rewrite ----------
491
489
 
492
490
  const textures = new Set<string>()
491
+ /** Channels the shader declares itself, as a WebGL port of a Shadertoy shader does, which need no second declaration. */
492
+ const declaredChannels = new Set<string>()
493
493
  let mouse = false
494
494
  let uvDropped = false
495
495
 
@@ -530,6 +530,8 @@ export function fromGLSL(glsl: string, options: { file?: string } = {}): FromGLS
530
530
  // `uniform sampler2D tex;` is `texture2D tex;`.
531
531
  out += "texture2D"
532
532
  i = skip(i + 1)
533
+ const declares = toks[skip(i + 1)]?.text ?? ""
534
+ if (/^iChannel\d$/.test(declares)) declaredChannels.add(declares)
533
535
  continue
534
536
  }
535
537
  if (name === "sampler2D") { out += "texture2D"; continue }
@@ -616,6 +618,13 @@ export function fromGLSL(glsl: string, options: { file?: string } = {}): FromGLS
616
618
  call = `pow(2.0, ${a[0]})`
617
619
  } else if (name === "log2" && a.length === 1) {
618
620
  call = `(log(${a[0]}) * 1.4426950408889634)`
621
+ } else if ((name === "texelFetch" && a.length === 3) || (name === "textureSize" && a.length === 2)) {
622
+ // Both read the full size texture here, so the level goes.
623
+ const level = a[a.length - 1]!
624
+ if (!/^0(\.0*)?$/.test(level)) {
625
+ note(t.line, `${name} reads level ${level} in GLSL, and ${name === "texelFetch" ? "tex2Dfetch" : name} reads the full size texture here`)
626
+ }
627
+ call = name === "texelFetch" ? `tex2Dfetch(${a[0]}, ${a[1]})` : `textureSize(${a[0]})`
619
628
  }
620
629
  if (call !== null) { out += call; i = close; continue }
621
630
  }
@@ -683,7 +692,7 @@ export function fromGLSL(glsl: string, options: { file?: string } = {}): FromGLS
683
692
  note(at >= 0 ? toks[at]!.line : 1, "the uv line is dropped: fragCoord / resolution is the input uv, y up")
684
693
  }
685
694
 
686
- const declared = [...textures].sort().map((t) => `texture2D ${t};`)
695
+ const declared = [...textures].filter((t) => !declaredChannels.has(t)).sort().map((t) => `texture2D ${t};`)
687
696
  if (mouse) declared.unshift("uniform float4 mouse;")
688
697
  const source = (declared.length > 0 ? declared.join("\n") + "\n" : "") + body.replace(/^\n+/, "")
689
698
  const lineOf = (n: string) => Number(/^line (\d+)/.exec(n)?.[1] ?? 0)