onejs-sl 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "onejs-sl",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "The OneJS shader language: a small typed language for per pixel programs, compiled to a portable IR and emitted as HLSL, WGSL or GLSL ES",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
package/src/body.ts CHANGED
@@ -51,6 +51,14 @@ export interface BodyTarget {
51
51
  * as it is, and a host that composites premultiplied multiplies on write.
52
52
  */
53
53
  sample: (slot: number, uv: string) => string
54
+ /**
55
+ * A float4 sample of the texture in `slot` at `uv` from mip level `lod`, a
56
+ * float expression: 0 is the full size texture, 1 half, and a fraction
57
+ * blends two levels where the texture's filter does. Otherwise the same
58
+ * contract as `sample`. `tex2Dlod` in a program; HLSL's
59
+ * `tex2Dlod(s, float4(uv, 0, lod))` and Metal's `sample(s, uv, level(lod))`.
60
+ */
61
+ sampleLevel: (slot: number, uv: string, lod: string) => string
54
62
  /**
55
63
  * `linear`: `toLinear` calls `sl_toLinear`, for a host whose target holds
56
64
  * linear light. `gamma`: `toLinear` is the identity, decided here, so hex
@@ -100,6 +108,11 @@ export function emitBody(p: Program, target: BodyTarget): Body {
100
108
  const helpers = new Set<string>()
101
109
  const helper = (fn: string) => { helpers.add(fn); return fn }
102
110
  /** A node at width `w`: a scalar repeated into a constructor, since HLSL refuses float3(x) and Metal x.xxx. */
111
+ /** A constant node's value, or null for anything computed. */
112
+ const constantOf = (ref: number): number[] | null => {
113
+ const c = p.nodes[ref]!
114
+ return c.k === "const" ? c.v : null
115
+ }
103
116
  const splat = (ref: number, w: SLType) => (p.nodes[ref]!.type === TYPE.FLOAT && w > 1 ? ctor(w, Array(w).fill(name(ref))) : name(ref))
104
117
 
105
118
  const expr = (n: SLNode): string => {
@@ -192,14 +205,19 @@ export function emitBody(p: Program, target: BodyTarget): Body {
192
205
  case SLOP.HSV2RGB: return `${helper("sl_hsv2rgb")}(${a[0]})`
193
206
  case SLOP.NOISE: return `${helper("sl_valueNoise")}(${a[0]})`
194
207
  case SLOP.SIMPLEX: return `${helper("sl_simplex")}(${a[0]})`
195
- case SLOP.FBM: return `${helper("sl_fbm")}(${a[0]}, ${Math.round(imm[0] ?? 3)}, ${Math.round(imm[1] ?? 0)})`
196
- case SLOP.TURBULENCE: return `${helper("sl_fbm")}(${a[0]}, ${Math.round(imm[0] ?? 3)}, 2)`
197
- case SLOP.RIDGED: return `${helper("sl_fbm")}(${a[0]}, ${Math.round(imm[0] ?? 3)}, 3)`
208
+ case SLOP.FBM:
209
+ case SLOP.TURBULENCE:
210
+ case SLOP.RIDGED: {
211
+ // The count rounded here and held to 1 to 4 by sl_fbm; a constant
212
+ // one is the whole number it is. fbm's kind is the one immediate.
213
+ const c = constantOf(n.args[1]!)
214
+ const count = c !== null ? String(Math.round(c[0]!)) : `int(floor(${a[1]} + 0.5))`
215
+ const kind = n.op === SLOP.TURBULENCE ? 2 : n.op === SLOP.RIDGED ? 3 : Math.round(imm[0] ?? 0)
216
+ return `${helper("sl_fbm")}(${a[0]}, ${count}, ${kind})`
217
+ }
198
218
  case SLOP.SDF: {
199
219
  const id = Math.round(imm[0] ?? 0)
200
- const q = [imm[1] ?? 0, imm[2] ?? 0, imm[3] ?? 0, imm[4] ?? 0].map(lit)
201
- const r = [imm[5] ?? 0, imm[6] ?? 0].map(lit)
202
- return `${helper("sl_sdfDistance")}(${id}, ${a[0]}, float4(${q.join(", ")}), float2(${r.join(", ")}))`
220
+ return `${helper("sl_sdfDistance")}(${id}, ${a[0]}, ${a[1]}, ${a[2]})`
203
221
  }
204
222
  case SLOP.VORONOI: return `${helper("sl_voronoi")}(${a[0]})`
205
223
  case SLOP.SAMPLE: {
@@ -207,6 +225,11 @@ export function emitBody(p: Program, target: BodyTarget): Body {
207
225
  textures.add(slot)
208
226
  return target.sample(slot, a[0]!)
209
227
  }
228
+ case SLOP.SAMPLE_LOD: {
229
+ const slot = Math.round(imm[0] ?? 0)
230
+ textures.add(slot)
231
+ return target.sampleLevel(slot, a[0]!, a[1]!)
232
+ }
210
233
 
211
234
  default:
212
235
  throw new SLError(
package/src/core.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  export * as sl from "./sl"
11
11
  export type { Float, Vec2, Vec3, Vec4, Num, ProgramInputs, Texture } from "./sl"
12
12
  export {
13
- TYPE, INPUTS, MAX_NODES, SLError, hashProgram, widthName, SL_IR_VERSION, inputsUsed,
13
+ TYPE, INPUTS, MAX_NODES, SLError, hashProgram, widthName, SL_IR_VERSION, SL_HASH_VERSION, inputsUsed,
14
14
  CONTROL_FIELDS, controlProblem,
15
15
  } from "./ir"
16
16
  export type {
package/src/hlsl.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  * next time it is generated.
16
16
  */
17
17
 
18
- import { SLError, type Program } from "./ir"
18
+ import { SLError, SL_HASH_VERSION, type Program } from "./ir"
19
19
  import { emitBody, lit, type BodyTarget } from "./body"
20
20
 
21
21
  /** The property name a uniform gets. Prefixed so it cannot collide with ours. */
@@ -74,7 +74,9 @@ export function emitShader(p: Program, options: EmitOptions = {}): string {
74
74
  //
75
75
  // Source of truth is the program this was emitted from; edits here are lost the
76
76
  // next time it is generated. The name carries the program hash, which is how the
77
- // runtime pairs the two. See Specs/SHADER_LANG.md section 6.
77
+ // runtime pairs the two. See Specs/SHADER_LANG.md section 6. The scheme that
78
+ // hash was made under, which a build compares with the apps' manifests:
79
+ // SL_HASH_VERSION ${SL_HASH_VERSION}
78
80
  Shader "${name}"
79
81
  {
80
82
  Properties
@@ -123,7 +125,7 @@ ${body}
123
125
 
124
126
  /**
125
127
  * The frame's side of the body: OneJS's names for the inputs, a uniform's
126
- * material property, and `tex2D` on the slot's sampler. `sl_toLinear` stays a
128
+ * material property, and `tex2D` and `tex2Dlod` on the slot's sampler. `sl_toLinear` stays a
127
129
  * call because `SLCommon.cginc` decides Gamma or Linear per project, at the
128
130
  * shader compile, so this target always emits it.
129
131
  */
@@ -148,6 +150,7 @@ function unityTarget(p: Program): BodyTarget {
148
150
  },
149
151
  uniform: (slot) => uniformProperty(p.uniforms[slot]!.name),
150
152
  sample: (slot, uv) => `tex2D(_Tex${slot}, ${uv})`,
153
+ sampleLevel: (slot, uv, lod) => `tex2Dlod(_Tex${slot}, float4(${uv}, 0.0, ${lod}))`,
151
154
  colour: "linear",
152
155
  indent: " ",
153
156
  }
package/src/ir.ts CHANGED
@@ -171,7 +171,10 @@ export interface TextureDecl {
171
171
  }
172
172
 
173
173
  export interface Program {
174
- /** The `SL_IR_VERSION` this program's nodes mean what they mean under. */
174
+ /**
175
+ * The `SL_IR_VERSION` this program's nodes mean what they mean under: the
176
+ * lowest one that has every node it holds. See `programVersion`.
177
+ */
175
178
  version: number
176
179
  nodes: SLNode[]
177
180
  /** Must be VEC4: a program produces a colour. */
@@ -210,6 +213,24 @@ export function checkCaps(p: Pick<Program, "uniforms" | "textures">): void {
210
213
  if (p.textures.length > TEXTURE_SLOTS) throw new SLError(tooManyTextures(p.textures.length, "program"))
211
214
  }
212
215
 
216
+ /**
217
+ * The colour as written behind a colour value: the operand of the `TO_LINEAR`
218
+ * that reading a hex or a colour uniform applied, or null for a value computed
219
+ * in the working space. What a ramp's stop has to be, since a ramp blends its
220
+ * stops as written (`sl.ramp`).
221
+ */
222
+ export function writtenColour(nodes: readonly SLNode[], ref: NodeRef): NodeRef | null {
223
+ const n = nodes[ref]!
224
+ if (n.k !== "call" || n.op !== SLOP.TO_LINEAR || (n.type !== TYPE.VEC3 && n.type !== TYPE.VEC4)) return null
225
+ return n.args[0]!
226
+ }
227
+
228
+ /** Why a computed value is not a ramp's stop, in one wording for both surfaces. */
229
+ export const RAMP_STOP_COMPUTED =
230
+ "a ramp's stop is a colour as written: a hex like #ff8040, or a uniform or const that holds one. " +
231
+ "This one is computed, and a ramp blends its stops as written before it converts them, so it has " +
232
+ "no written colour to blend. Blend computed colours with lerp instead"
233
+
213
234
  /** Operations a single program may hold. Generous: a guard against a runaway builder, not a budget. */
214
235
  export const MAX_NODES = 4096
215
236
 
@@ -402,6 +423,13 @@ export function hashProgram(nodes: SLNode[], result: NodeRef, uniforms: UniformD
402
423
  // digests, so two graphs that compute the same thing agree however they were
403
424
  // assembled. It also ignores nodes not reachable from the result, which is
404
425
  // correct: dead nodes generate no shader code.
426
+ //
427
+ // NORMALISED, so two ways of writing one computation agree (hash version 2).
428
+ // A commutative op's operands are digested in sorted order, so `a + b` is
429
+ // `b + a`; and a swizzle of a constant is digested as the constant it picks,
430
+ // so `8 * uv` (8 broadcast by a swizzle) is `uv * 8` (a float2 constant).
431
+ // Only where every backend computes the same bits either way: min and max
432
+ // are left out, since which of -0 and +0 they return depends on the order.
405
433
  const digest = new Map<NodeRef, string>()
406
434
 
407
435
  const of = (ref: NodeRef): string => {
@@ -417,8 +445,19 @@ export function hashProgram(nodes: SLNode[], result: NodeRef, uniforms: UniformD
417
445
  // produce different shaders and must not share a hash. The names go
418
446
  // in separately below.
419
447
  case "uniform": body = `u:${n.slot}:${n.type}`; break
420
- case "swizzle": body = `s:${of(n.src)}:${n.chans.join("")}`; break
421
- case "call": body = `f:${n.op}:${n.args.map(of).join(",")}:${(n.imm ?? []).map(fixed).join(",")}`; break
448
+ case "swizzle": {
449
+ const src = nodes[n.src]!
450
+ body = src.k === "const"
451
+ ? `c:${n.type}:${n.chans.map((c) => fixed(src.v[c]!)).join(",")}`
452
+ : `s:${of(n.src)}:${n.chans.join("")}`
453
+ break
454
+ }
455
+ case "call": {
456
+ const args = n.args.map(of)
457
+ if (COMMUTATIVE.has(n.op)) args.sort()
458
+ body = `f:${n.op}:${args.join(",")}:${(n.imm ?? []).map(fixed).join(",")}`
459
+ break
460
+ }
422
461
  }
423
462
  const d = fnv1a(body + "|" + n.type)
424
463
  digest.set(ref, d)
@@ -426,12 +465,15 @@ export function hashProgram(nodes: SLNode[], result: NodeRef, uniforms: UniformD
426
465
  }
427
466
 
428
467
  // The IR version is in the hash, so a change to what an opcode means in the
429
- // IR changes every hash and every cache keyed by one recompiles rather than
430
- // serving the old maths. A change to the helper library alone (a noise's
431
- // arithmetic, say) is not one: every host includes the library's text when
432
- // it compiles, and none caches a compiled program by hash across a
433
- // library update, so the new text reaches every shader without a bump.
434
- const parts: string[] = [`v${SL_HASH_VERSION}:${SL_IR_VERSION}`, of(result)]
468
+ // IR changes the hash of every program using it, and every cache keyed by
469
+ // one recompiles rather than serving the old maths. It is the PROGRAM's
470
+ // version, the lowest that has its nodes, so a version that adds a node
471
+ // leaves every program without one on the hash it had. A change to the
472
+ // helper library alone (a noise's arithmetic, say) is not one: every host
473
+ // includes the library's text when it compiles, and none caches a compiled
474
+ // program by hash across a library update, so the new text reaches every
475
+ // shader without a bump.
476
+ const parts: string[] = [`v${SL_HASH_VERSION}:${programVersion(nodes)}`, of(result)]
435
477
  for (const u of uniforms) parts.push(`U:${u.name}:${u.type}:${u.value.map(fixed).join(",")}`)
436
478
  for (const t of textures) parts.push(`T:${t.name}:${t.slot}`)
437
479
  return fnv1a(parts.join("|"))
@@ -449,8 +491,79 @@ export function controlOf(c: UniformControl): UniformControl {
449
491
  return out
450
492
  }
451
493
 
452
- /** Bumped when the hashing scheme changes, which invalidates generated shaders. */
453
- export const SL_HASH_VERSION = 1
494
+ /**
495
+ * The lowest `SL_IR_VERSION` that has every node here, and never below 2.
496
+ *
497
+ * A version that adds a node, or a form of one, raises only the programs that
498
+ * hold it. Everything else keeps the version, the hash and the JSON it had, so
499
+ * a program recorded before the bump still finds its shader, and a reader that
500
+ * predates the bump still reads it. Every node counts, used or not: the JSON
501
+ * carries them all, and an older reader has to refuse by the version rather
502
+ * than halfway through the nodes.
503
+ */
504
+ export function programVersion(nodes: readonly SLNode[]): number {
505
+ let v = 2
506
+ for (const n of nodes) v = Math.max(v, nodeVersion(n))
507
+ return v
508
+ }
509
+
510
+ /**
511
+ * The version that added this node's form. An sdf or octave call with one
512
+ * operand is the form before IR 3, which only a file an older compiler wrote
513
+ * holds, and `fromJSON` moves it to the form IR 3 has.
514
+ */
515
+ function nodeVersion(n: SLNode): number {
516
+ if (n.k !== "call") return 2
517
+ switch (n.op) {
518
+ case SLOP.SAMPLE_LOD: return 3
519
+ case SLOP.SDF:
520
+ case SLOP.FBM:
521
+ case SLOP.TURBULENCE:
522
+ case SLOP.RIDGED:
523
+ return n.args.length > 1 ? 3 : 2
524
+ default: return 2
525
+ }
526
+ }
527
+
528
+ /**
529
+ * What is wrong with a call's form, for the ops whose operand list alone does
530
+ * not say it, or null. The builder only makes the right ones; this is for a
531
+ * program read from JSON, which an emitter would otherwise print as something
532
+ * else.
533
+ *
534
+ * SDF the point, a float4 and a float2 holding the six shape parameters,
535
+ * the shape id the one immediate
536
+ * FBM, TURBULENCE, RIDGED the point and the octave count, FBM's kind the
537
+ * one immediate
538
+ */
539
+ export function formProblem(n: Extract<SLNode, { k: "call" }>, nodes: readonly SLNode[]): string | null {
540
+ const widths = n.args.map((a) => nodes[a]!.type).join(",")
541
+ switch (n.op) {
542
+ case SLOP.SDF:
543
+ if (widths === "2,4,2" && n.imm?.length === 1) return null
544
+ return "an sdf takes a float2, a float4 and a float2, and its shape as the one immediate"
545
+ case SLOP.FBM:
546
+ case SLOP.TURBULENCE:
547
+ case SLOP.RIDGED: {
548
+ const rest = n.op === SLOP.FBM ? 1 : 0
549
+ if (widths === "2,1" && (n.imm?.length ?? 0) === rest) return null
550
+ return `${SL_NAME[n.op]} takes a float2 and its octave count, a float`
551
+ }
552
+ default:
553
+ return null
554
+ }
555
+ }
556
+
557
+ /** The ops whose two operands the hash takes in either order: exact in IEEE arithmetic on every backend. */
558
+ const COMMUTATIVE = new Set<number>([SLOP.ADD, SLOP.MUL, SLOP.DOT, SLOP.DISTANCE])
559
+
560
+ /**
561
+ * Bumped when the hashing scheme changes, which invalidates generated shaders.
562
+ *
563
+ * 1 the Merkle hash over the reachable graph
564
+ * 2 normalised: commutative operands in either order, a swizzle of a constant as the constant
565
+ */
566
+ export const SL_HASH_VERSION = 2
454
567
 
455
568
  /**
456
569
  * Bumped whenever the IR changes in a way a reader has to know about: a new
@@ -467,13 +580,16 @@ export const SL_HASH_VERSION = 1
467
580
  * records it again, for no gain.
468
581
  *
469
582
  * A reader accepts every version up to its own and refuses a newer one with a
470
- * message naming both, the rule the particle wire and fx follow. Part of the
471
- * hash, so a bump recompiles every cached shader.
583
+ * message naming both, the rule the particle wire and fx follow. A program's
584
+ * version is the lowest that has its nodes (`programVersion`), and that is what
585
+ * its hash carries, so a bump rehashes only the programs using what it added.
472
586
  *
473
587
  * 1 the first versioned IR
474
588
  * 2 an SDF call carries up to six shape parameters, not four (#129)
589
+ * 3 SAMPLE_LOD; an SDF's shape parameters and a noise's octave count are
590
+ * operands, where they were immediates
475
591
  */
476
- export const SL_IR_VERSION = 2
592
+ export const SL_IR_VERSION = 3
477
593
 
478
594
  function fnv1a(s: string): string {
479
595
  let h = 0x811c9dc5
@@ -152,13 +152,13 @@ one the local does not have, is refused.
152
152
 
153
153
  ## Not yet spellable
154
154
 
155
- Three things the EDSL or the opcode table has and a file cannot say. Each is a
155
+ Two things the EDSL or the opcode table has and a file cannot say. Each is a
156
156
  recorded gap, not an oversight, and each has its own error message rather than
157
157
  "unknown identifier".
158
158
 
159
159
  | | Why |
160
160
  |---|---|
161
- | `rgb2hsv`, `tex2Dlod` | The opcodes are numbered and **no backend implements them**; every emitter throws on them. |
161
+ | `rgb2hsv` | The opcode is numbered and **no backend implements it**; every emitter throws on it. |
162
162
  | `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
163
 
164
164
  A `.sl` file and the EDSL are held to the same caps, `UNIFORM_SLOTS` (16) and
@@ -125,12 +125,10 @@ value(SLOP.LUMINANCE, 1, 1, (a) => sl.luminance(colourOperand("luminance", a[0]!
125
125
  value(SLOP.NOISE, 1, 1, (a) => sl.noise(point("noise", a[0]!) as never))
126
126
  value(SLOP.SIMPLEX, 1, 1, (a) => sl.simplex(point("simplex", a[0]!) as never))
127
127
  value(SLOP.VORONOI, 1, 1, (a) => sl.voronoi(point("voronoi", a[0]!) as never))
128
- // The octave count is an immediate, so it has to be a number at parse time.
129
- // `lower.ts` folds constants before it gets here and rejects anything else with
130
- // a message that says why, rather than letting this cast be the error.
131
- value(SLOP.FBM, 1, 2, (a) => sl.fbm(point("fbm", a[0]!) as never, octaves("fbm", a[1])))
132
- value(SLOP.TURBULENCE, 1, 2, (a) => sl.turbulence(point("turbulence", a[0]!) as never, octaves("turbulence", a[1])))
133
- value(SLOP.RIDGED, 1, 2, (a) => sl.ridged(point("ridged", a[0]!) as never, octaves("ridged", a[1])))
128
+ // The octave count is 3 when left out, and may be a value (`sl.fbm` says how).
129
+ value(SLOP.FBM, 1, 2, (a) => sl.fbm(point("fbm", a[0]!) as never, a[1] ?? 3))
130
+ value(SLOP.TURBULENCE, 1, 2, (a) => sl.turbulence(point("turbulence", a[0]!) as never, a[1] ?? 3))
131
+ value(SLOP.RIDGED, 1, 2, (a) => sl.ridged(point("ridged", a[0]!) as never, a[1] ?? 3))
134
132
 
135
133
  /** Every field is sampled at a float2. */
136
134
  function point(fn: string, v: Num): Val {
@@ -138,20 +136,10 @@ function point(fn: string, v: Num): Val {
138
136
  }
139
137
 
140
138
  special(SLOP.SAMPLE, 2, 2)
139
+ special(SLOP.SAMPLE_LOD, 3, 3)
141
140
  special(SLOP.SDF, 1, 7)
142
141
  special(SLOP.RAMP, 3, 64)
143
142
 
144
- function octaves(fn: string, v: Num | undefined): number {
145
- if (v === undefined) return 3
146
- if (typeof v !== "number") {
147
- throw new SLError(
148
- `${fn}'s octave count is part of the operation, so it has to be a constant. ` +
149
- `A uniform or a computed value cannot change how many octaves the shader runs.`,
150
- )
151
- }
152
- return v
153
- }
154
-
155
143
  export const BUILTINS: Readonly<Record<string, Builtin>> = table
156
144
 
157
145
  /**
package/src/lang/check.ts CHANGED
@@ -49,11 +49,15 @@ const DERIVED_NAMES = new Set(Object.keys(DERIVED_INPUTS))
49
49
  * The GLSL spellings whose HLSL name means the same wherever it is written, so
50
50
  * the hint can be a one click fix (`Specs/SL_NEXT.md` 5, Decision 1 A). The
51
51
  * rest of `SL_GLSL_HINT` gets the hint alone: `mod` floors where `%` truncates,
52
- * `ivec2` truncates where `float2` does not, `atan` is `atan2` only with two
53
- * arguments (handled at the call), and `textureLod` names a builtin that is not
54
- * implemented yet.
52
+ * `ivec2` truncates where `float2` does not, and `atan` is `atan2` only with two
53
+ * arguments (handled at the call).
55
54
  */
56
- const GLSL_RENAMES = new Set(["mix", "fract", "texture", "vec2", "vec3", "vec4", "gl_FragCoord", "iTime", "iResolution"])
55
+ const GLSL_RENAMES = new Set([
56
+ "mix", "fract", "texture", "textureLod", "vec2", "vec3", "vec4", "gl_FragCoord", "iTime", "iResolution",
57
+ ])
58
+
59
+ /** The builtins whose first argument is a texture, which is not a value anywhere else. */
60
+ const SAMPLES = new Set(["tex2D", "tex2Dlod"])
57
61
 
58
62
  const rename = (from: string, to: string): SLFix => ({ title: `Replace ${from} with ${to}`, replacement: to })
59
63
 
@@ -427,7 +431,7 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
427
431
  if (textures.has(e.name)) {
428
432
  fail(
429
433
  `"${e.name}" is a texture, and a texture is only ever the first argument of ` +
430
- `tex2D. Write \`tex2D(${e.name}, uv)\``,
434
+ `tex2D or tex2Dlod. Write \`tex2D(${e.name}, uv)\``,
431
435
  e.pos, e.name.length,
432
436
  )
433
437
  }
@@ -458,9 +462,9 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
458
462
  // Only reached while collecting: the call was refused, and its
459
463
  // arguments are separate questions, so a mistake inside one is
460
464
  // not hidden by the call's own. `fract(uv * wrap)` is two
461
- // mistakes. tex2D's first argument is the texture, which is a
462
- // value only there.
463
- const tex = e.callee.k === "ident" && e.callee.name === "tex2D"
465
+ // mistakes. A sample's first argument is the texture, which is
466
+ // not a value.
467
+ const tex = e.callee.k === "ident" && SAMPLES.has(e.callee.name)
464
468
  for (const a of tex ? e.args.slice(1) : e.args) attempt(() => checkExpr(fn, a, scope))
465
469
  return
466
470
  }
@@ -529,13 +533,22 @@ export function check(unit: Unit, prelude: FuncDecl[], options: CheckOptions = {
529
533
 
530
534
  const builtin = BUILTINS[n]
531
535
  if (builtin !== undefined) {
532
- if (n === "tex2D") {
536
+ if (SAMPLES.has(n)) {
533
537
  const [tex, ...rest] = e.args
538
+ // HLSL's own tex2Dlod packs the level into a float4 with the uv.
539
+ if (n === "tex2Dlod" && e.args.length === 2) {
540
+ fail(
541
+ "tex2Dlod takes the uv and the mip level separately, `tex2Dlod(t, uv, lod)`, " +
542
+ "rather than HLSL's float4(uv, 0, lod)",
543
+ e.pos,
544
+ )
545
+ }
534
546
  arity(e, n, builtin.min, builtin.max)
535
547
  if (tex === undefined || tex.k !== "ident" || !textures.has(tex.name)) {
548
+ const lod = n === "tex2Dlod" ? ", 0" : ""
536
549
  fail(
537
- `tex2D samples a texture declared in this file, as in \`texture2D grain;\` ` +
538
- `then \`tex2D(grain, uv)\``,
550
+ `${n} samples a texture declared in this file, as in \`texture2D grain;\` ` +
551
+ `then \`${n}(grain, uv${lod})\``,
539
552
  (tex ?? e).pos,
540
553
  )
541
554
  }
package/src/lang/glsl.ts CHANGED
@@ -75,7 +75,7 @@ const GLSL_TYPES = new Set(["float", "int", "uint", "bool", "vec2", "vec3", "vec
75
75
  /** A straight rename, the meaning unchanged. */
76
76
  const RENAME: Record<string, string> = {
77
77
  vec2: "float2", vec3: "float3", vec4: "float4", mix: "lerp", fract: "frac",
78
- texture: "tex2D", texture2D: "tex2D", iTime: "time", iGlobalTime: "time", gl_FragCoord: "fragCoord",
78
+ texture: "tex2D", texture2D: "tex2D", textureLod: "tex2Dlod", iTime: "time", iGlobalTime: "time", gl_FragCoord: "fragCoord",
79
79
  }
80
80
 
81
81
  /** A rename that changes what the value can hold, so it is said. */
@@ -101,7 +101,6 @@ const MISSING: Record<string, string> = {
101
101
  iChannelTime: "a texture has no time of its own",
102
102
  iChannelResolution: "a texture's size cannot be read yet",
103
103
  iSampleRate: "there is no audio",
104
- textureLod: "tex2Dlod is not implemented yet; tex2D samples with the texture's own filtering",
105
104
  texelFetch: "a texture cannot be read without filtering yet",
106
105
  textureSize: "a texture's size cannot be read yet",
107
106
  dFdx: "there are no derivatives", dFdy: "there are no derivatives", fwidth: "there are no derivatives",
package/src/lang/lower.ts CHANGED
@@ -32,7 +32,9 @@
32
32
  * than an optimisation.
33
33
  */
34
34
 
35
- import { controlProblem, DERIVED_INPUTS, INPUTS, type Program, type SLType, type UniformControl } from "../ir"
35
+ import {
36
+ controlProblem, DERIVED_INPUTS, INPUTS, RAMP_STOP_COMPUTED, writtenColour, type Program, type SLType, type UniformControl,
37
+ } from "../ir"
36
38
  import type { SlSdfKind } from "../shapes"
37
39
  import * as sl from "../sl"
38
40
  import type { Num, Val } from "../sl"
@@ -602,19 +604,7 @@ export function lower(checked: Checked, errors?: SLParseError[]): Program {
602
604
  if (typeof p === "number" || p.width !== 2) {
603
605
  fail(`sdf.${shape} measures the distance to a point, so it takes a float2`, e.args[0]!.pos)
604
606
  }
605
- const params: number[] = []
606
- for (const a of e.args.slice(1)) {
607
- const v = lowerExpr(a, scope)
608
- if (typeof v !== "number") {
609
- fail(
610
- `sdf.${shape}'s shape parameters are part of the operation, so they have to ` +
611
- `be constants. A size that changes belongs on the point: scale or offset it ` +
612
- `before the call`,
613
- a.pos,
614
- )
615
- }
616
- params.push(v)
617
- }
607
+ const params = e.args.slice(1).map((a) => lowerExpr(a, scope))
618
608
  return at(e.pos, () => sl.sdf(shape, p as never, params))
619
609
  }
620
610
 
@@ -624,29 +614,35 @@ export function lower(checked: Checked, errors?: SLParseError[]): Program {
624
614
  return construct(n as TypeName, e.args.map((a) => lowerExpr(a, scope)), e.pos)
625
615
  }
626
616
 
627
- if (n === "tex2D") {
617
+ if (n === "tex2D" || n === "tex2Dlod") {
628
618
  const texName = (e.args[0] as Extract<Expr, { k: "ident" }>).name
629
619
  const tex = samplers.get(texName)!
630
620
  const uv = lowerExpr(e.args[1]!, scope)
631
621
  if (typeof uv === "number" || uv.width !== 2) {
632
- fail("tex2D samples at a float2", e.args[1]!.pos)
622
+ fail(`${n} samples at a float2`, e.args[1]!.pos)
633
623
  }
634
- return at(e.pos, () => tex.sample(uv as never) as unknown as Val)
624
+ if (n === "tex2D") return at(e.pos, () => tex.sample(uv as never) as unknown as Val)
625
+ const lod = lowerExpr(e.args[2]!, scope)
626
+ if (typeof lod !== "number" && lod.width !== 1) {
627
+ fail(`a mip level is one number, and this is a ${widthType(lod.width)}`, e.args[2]!.pos)
628
+ }
629
+ return at(e.pos, () => tex.sampleLevel(uv as never, lod) as unknown as Val)
635
630
  }
636
631
 
637
632
  if (n === "ramp") {
638
633
  const t = lowerExpr(e.args[0]!, scope)
639
- const stops: string[] = []
640
- for (const a of e.args.slice(1)) {
641
- if (a.k !== "hex") {
642
- fail(
643
- "a ramp's stops are constants, written as colours: `ramp(t, #000018, #0080ff, " +
644
- "#ffffff)`. A stop that changes wants lerp between two uniforms instead",
645
- a.pos,
646
- )
634
+ // A hex stays the string it was, so a ramp of hexes is the program
635
+ // it always was. Anything else is a value, which has to be a colour
636
+ // as written (`sl.ramp` says why), a colour uniform say.
637
+ const stops = e.args.slice(1).map((a) => {
638
+ if (a.k === "hex") return a.hex
639
+ const v = lowerExpr(a, scope)
640
+ if (typeof v === "number" || v.width < 3) {
641
+ fail(`a ramp's stop is a colour, a float3 or a float4, and this is a ${typeof v === "number" ? "float" : widthType(v.width)}`, a.pos)
647
642
  }
648
- stops.push(a.hex)
649
- }
643
+ if (writtenColour(v.owner.nodes, v.ref) === null) fail(RAMP_STOP_COMPUTED, a.pos)
644
+ return v as never
645
+ })
650
646
  return at(e.pos, () => sl.ramp(t, stops) as unknown as Val)
651
647
  }
652
648
 
package/src/ops.ts CHANGED
@@ -156,8 +156,10 @@ export const SL_ARITY: Record<number, number> = {
156
156
 
157
157
  [SLOP.RAMP]: -1, [SLOP.HSV2RGB]: 1, [SLOP.RGB2HSV]: 1, [SLOP.LUMINANCE]: 1, [SLOP.TO_LINEAR]: 1,
158
158
 
159
- [SLOP.NOISE]: 1, [SLOP.SIMPLEX]: 1, [SLOP.FBM]: 1, [SLOP.SDF]: 1,
160
- [SLOP.VORONOI]: 1, [SLOP.TURBULENCE]: 1, [SLOP.RIDGED]: 1,
159
+ // An SDF takes the point, or the point and its parameters as operands; an
160
+ // octave op the point, or the point and its count (`formProblem` in ir.ts).
161
+ [SLOP.NOISE]: 1, [SLOP.SIMPLEX]: 1, [SLOP.FBM]: -1, [SLOP.SDF]: -1,
162
+ [SLOP.VORONOI]: 1, [SLOP.TURBULENCE]: -1, [SLOP.RIDGED]: -1,
161
163
 
162
164
  [SLOP.SAMPLE]: 1, [SLOP.SAMPLE_LOD]: 2,
163
165
  }
@@ -282,7 +284,6 @@ export const SL_HLSL: Record<number, SLSurface> = {
282
284
  */
283
285
  export const SL_UNIMPLEMENTED: Record<number, string> = {
284
286
  [SLOP.RGB2HSV]: "no backend has a case for it yet",
285
- [SLOP.SAMPLE_LOD]: "no backend samples at an explicit LOD yet; use tex2D",
286
287
  [SLOP.REMAP]: "the surface form is a macro over arithmetic, so the opcode is unused",
287
288
  }
288
289
 
package/src/params.ts CHANGED
@@ -54,6 +54,7 @@ const INTRINSIC: Record<string, readonly string[]> = {
54
54
  distance: ["x", "y"], dot: ["x", "y"], cross: ["x", "y"], reflect: ["i", "n"],
55
55
  lerp: ["x", "y", "s"], step: ["y", "x"], smoothstep: ["min", "max", "x"],
56
56
  tex2D: ["s", "t"],
57
+ tex2Dlod: ["s", "t", "lod"],
57
58
  }
58
59
 
59
60
  /**
@@ -161,6 +162,7 @@ export const BUILTIN_DOCS: Readonly<Record<string, string>> = {
161
162
  turbulence: "Simplex creases layered in octaves, 1 to 4: fire, smoke, marble.",
162
163
  ridged: "Turbulence made bright at its creases: ridges, lightning, cracks.",
163
164
  tex2D: "The colour of texture s at the uv t, with straight alpha.",
165
+ tex2Dlod: "The colour of texture s at the uv t from mip level lod: 0 is the full size, 1 half, and so on.",
164
166
  sdf: "The signed distance from p to a shape, negative inside: `sdf.circle(p, r)`.",
165
167
  ramp: "t mapped through evenly spaced colour stops, from the first at 0 to the last at 1.",
166
168
  }