instantshader 0.1.0 → 0.3.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/README.md CHANGED
@@ -22,3 +22,51 @@ const handle = mountGradient(document.getElementById("bg")!, {
22
22
 
23
23
  // handle.pause() / handle.resume() / handle.dispose() when done
24
24
  ```
25
+
26
+ ## Seamless loops
27
+
28
+ Set `loopSeconds` and the animation repeats exactly, with no visible seam at
29
+ the wrap — the frame at `t` and at `t + loopSeconds` are identical pixel for
30
+ pixel. Built for video export and for backgrounds that must not betray a
31
+ restart.
32
+
33
+ ```ts
34
+ mountGradient(el, { shader: flow, colors, loopSeconds: 30 });
35
+ ```
36
+
37
+ It works the same on the one-shot renderer, which is how you'd drive an
38
+ encoder:
39
+
40
+ ```ts
41
+ const LOOP = 20;
42
+ for (let frame = 0; frame < 30 * LOOP; frame++) {
43
+ const { canvas, dispose } = renderGradientFrame({
44
+ shader: flow,
45
+ colors,
46
+ loopSeconds: LOOP,
47
+ timeMs: (frame / 30) * 1000,
48
+ width: 1920,
49
+ height: 1080,
50
+ });
51
+ // ...encode canvas, then:
52
+ dispose();
53
+ }
54
+ ```
55
+
56
+ Notes:
57
+
58
+ - The period is measured in **animation** seconds, so it interacts with
59
+ `speed`: a 90s loop at `speed: 4` completes in 22.5 wall-clock seconds while
60
+ still containing 90 seconds of motion. That pairing is how you get a short,
61
+ light video file without slowing the animation down.
62
+ - **`flow` ties its travel speed to the loop length.** It animates by
63
+ translating in a straight line through a noise field that tiles, and it
64
+ covers exactly one tile per cycle — so a short loop flows fast and a long
65
+ one flows slowly. The hand-tuned drift rate corresponds to a period around
66
+ 60–90s; below ~30s the currents move noticeably faster than the look was
67
+ designed for. Compensate with `speed` rather than by shortening the loop.
68
+ - **`beam` freezes its width swell below ~29s.** Its natural cycle is ~57s and
69
+ cannot be squeezed into a short loop without becoming a throb, so under that
70
+ threshold the swell holds still instead. Everything else still animates.
71
+ - Any loop necessarily revisits the same state every N seconds; a long period
72
+ is what buys the impression of never repeating.
package/dist/index.d.ts CHANGED
@@ -4,6 +4,9 @@ type Renderer = {
4
4
  renderAt(timeMs: number): void;
5
5
  setColors(colors: string[]): void;
6
6
  setParams(params: Record<string, number>): void;
7
+ /** Sets the seamless-loop period in animation seconds; 0/undefined disables
8
+ * looping. See RendererOptions.loopSeconds. */
9
+ setLoopSeconds(seconds: number | undefined): void;
7
10
  resize(width: number, height: number): void;
8
11
  dispose(): void;
9
12
  };
@@ -56,6 +59,23 @@ type MountOptions = {
56
59
  speed?: number;
57
60
  /** RNG seed for any randomized/time-offset behavior. Defaults to 0. */
58
61
  seed?: number;
62
+ /**
63
+ * Makes the animation repeat exactly every `loopSeconds`, with no visible
64
+ * seam at the wrap — the frame at t and at t + loopSeconds are identical
65
+ * pixel for pixel. Intended for video export and for backgrounds that must
66
+ * not betray a restart. Omit (the default) for an animation that never
67
+ * repeats.
68
+ *
69
+ * Measured in ANIMATION seconds, so it interacts with `speed`: a 10s loop
70
+ * at speed 2 completes in 5 wall-clock seconds. Leave `speed` at 1 when
71
+ * exporting to a fixed-length video.
72
+ *
73
+ * Short periods are where the cost shows. Under ~29s beam's width swell
74
+ * stops animating (see loopFreq in the GLSL preamble), and below ~10s the
75
+ * rotation of the drift direction becomes noticeable as a slow circling of
76
+ * the whole composition. 15-60s is the comfortable range.
77
+ */
78
+ loopSeconds?: number;
59
79
  };
60
80
  /** Live handle returned by mount(), used to control a running gradient instance. */
61
81
  type MountHandle = {
@@ -63,6 +83,11 @@ type MountHandle = {
63
83
  setColors(colors: string[]): void;
64
84
  setParams(params: Record<string, number>): void;
65
85
  setSpeed(speed: number): void;
86
+ /** Changes the seamless-loop period; pass undefined (or 0) to stop looping.
87
+ * See MountOptions.loopSeconds. Takes effect on the next painted frame, and
88
+ * because the shader clock wraps at the period, changing this mid-playback
89
+ * jumps the animation rather than easing into the new cycle. */
90
+ setLoopSeconds(seconds: number | undefined): void;
66
91
  pause(): void;
67
92
  resume(): void;
68
93
  /** Jumps playback to an absolute time position, in milliseconds. */
@@ -87,6 +112,9 @@ type RendererOptions = {
87
112
  colors: string[];
88
113
  params: Record<string, number>;
89
114
  seed: number;
115
+ /** Seamless-loop period in animation seconds; omitted/0 disables looping.
116
+ * See MountOptions.loopSeconds. */
117
+ loopSeconds?: number;
90
118
  };
91
119
  //#endregion
92
120
  //#region src/shaders/flow.d.ts
@@ -135,6 +163,11 @@ declare function renderGradientFrame(opts: {
135
163
  params?: Record<string, number>;
136
164
  seed?: number;
137
165
  timeMs?: number;
166
+ /** Seamless-loop period in animation seconds. Only meaningful here in that
167
+ * it makes `timeMs` and `timeMs + loopSeconds * 1000` render the same
168
+ * frame — which is exactly how a loop is verified. See
169
+ * MountOptions.loopSeconds. */
170
+ loopSeconds?: number;
138
171
  width: number;
139
172
  height: number;
140
173
  }): RenderFrameResult;
package/dist/index.js CHANGED
@@ -36,6 +36,69 @@ float snoise(vec2 v) {
36
36
  }
37
37
  `;
38
38
  /**
39
+ * Classic 2D Perlin noise with an EXPLICIT TILING PERIOD, copied from the
40
+ * standard reference implementation (Stefan Gustavson / Ashima Arts
41
+ * webgl-noise, public domain). As with SIMPLEX_2D, the constants are fitted
42
+ * values — do not "tidy" them.
43
+ *
44
+ * Why this exists alongside snoise: a shader that animates by translating
45
+ * its sample point through a noise field can only loop if the field repeats
46
+ * along the direction of travel. Simplex cannot do that at any useful
47
+ * distance (its permutation repeats every 289 skewed lattice cells), so a
48
+ * looping translation has to be bent into a circle instead — which reads as
49
+ * the composition swaying back and forth rather than flowing. `pnoise` wraps
50
+ * its integer lattice at `rep`, so travelling exactly `rep` units lands on a
51
+ * bit-identical field and the motion can stay perfectly straight.
52
+ *
53
+ * `rep` MUST be integral (it is fed to mod() on lattice coordinates); a
54
+ * fractional period silently produces a discontinuity at the wrap.
55
+ */
56
+ const PERIODIC_2D = `
57
+ vec4 mod289_4(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
58
+ vec4 permute4(vec4 x) { return mod289_4(((x * 34.0) + 1.0) * x); }
59
+ vec4 taylorInvSqrt4(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
60
+ vec2 fade2(vec2 t) { return t * t * t * (t * (t * 6.0 - 15.0) + 10.0); }
61
+
62
+ float pnoise(vec2 P, vec2 rep) {
63
+ vec4 Pi = floor(P.xyxy) + vec4(0.0, 0.0, 1.0, 1.0);
64
+ vec4 Pf = fract(P.xyxy) - vec4(0.0, 0.0, 1.0, 1.0);
65
+ Pi = mod(Pi, rep.xyxy); // the tiling itself
66
+ Pi = mod289_4(Pi); // keeps the permutation away from float truncation
67
+ vec4 ix = Pi.xzxz;
68
+ vec4 iy = Pi.yyww;
69
+ vec4 fx = Pf.xzxz;
70
+ vec4 fy = Pf.yyww;
71
+
72
+ vec4 i = permute4(permute4(ix) + iy);
73
+
74
+ vec4 gx = fract(i * (1.0 / 41.0)) * 2.0 - 1.0;
75
+ vec4 gy = abs(gx) - 0.5;
76
+ vec4 tx = floor(gx + 0.5);
77
+ gx = gx - tx;
78
+
79
+ vec2 g00 = vec2(gx.x, gy.x);
80
+ vec2 g10 = vec2(gx.y, gy.y);
81
+ vec2 g01 = vec2(gx.z, gy.z);
82
+ vec2 g11 = vec2(gx.w, gy.w);
83
+
84
+ vec4 norm = taylorInvSqrt4(vec4(dot(g00, g00), dot(g01, g01), dot(g10, g10), dot(g11, g11)));
85
+ g00 *= norm.x;
86
+ g01 *= norm.y;
87
+ g10 *= norm.z;
88
+ g11 *= norm.w;
89
+
90
+ float n00 = dot(g00, vec2(fx.x, fy.x));
91
+ float n10 = dot(g10, vec2(fx.y, fy.y));
92
+ float n01 = dot(g01, vec2(fx.z, fy.z));
93
+ float n11 = dot(g11, vec2(fx.w, fy.w));
94
+
95
+ vec2 fade_xy = fade2(Pf.xy);
96
+ vec2 n_x = mix(vec2(n00, n01), vec2(n10, n11), fade_xy.x);
97
+ float n_xy = mix(n_x.x, n_x.y, fade_xy.y);
98
+ return 2.3 * n_xy;
99
+ }
100
+ `;
101
+ /**
39
102
  * Fractal Brownian motion: sums octaves of snoise at doubling frequency
40
103
  * (lacunarity 2.0) and halving amplitude (gain 0.5), so each added octave
41
104
  * layers in finer detail at proportionally less visual weight. This is
@@ -129,27 +192,38 @@ float grain(vec2 uv, float time) {
129
192
  //#region src/shaders/flow.ts
130
193
  const FRAGMENT$1 = `
131
194
  uniform float u_scale;
195
+ uniform float u_curl;
132
196
  uniform float u_drift;
133
197
  uniform float u_openness;
134
198
  uniform float u_grain;
135
199
 
136
200
  ${SIMPLEX_2D}
201
+ ${PERIODIC_2D}
137
202
  ${FBM}
138
203
  ${SHAPE}
139
204
  ${GRAIN}
140
205
 
141
- // Curl of a scalar simplex field: the finite-difference gradient of snoise,
142
- // rotated 90 degrees -- (dPsi/dy, -dPsi/dx) instead of (dPsi/dx, dPsi/dy).
143
- // A rotated gradient is always divergence-free, which is the whole trick:
144
- // advecting a point along it produces swirling motion with nothing to make
145
- // it converge or diverge, unlike advecting along the gradient itself.
146
- vec2 curl(vec2 p) {
147
- // Finite-difference step: small enough to approximate a derivative,
148
- // large enough that snoise's own float precision doesn't swamp the
149
- // difference between the two samples.
206
+ // Curl of a scalar potential field: the finite-difference gradient, rotated
207
+ // 90 degrees -- (dPsi/dy, -dPsi/dx) instead of (dPsi/dx, dPsi/dy). A rotated
208
+ // gradient is always divergence-free, which is the whole trick: advecting a
209
+ // point along it produces swirling motion with nothing to make it converge
210
+ // or diverge, unlike advecting along the gradient itself.
211
+ //
212
+ // The potential is pnoise, not snoise, and it is pnoise in BOTH looping and
213
+ // non-looping modes on purpose. A tiling field is what lets the drift travel
214
+ // in a straight line and still return (see loopTravel), and having the two
215
+ // modes disagree about which noise they use would mean tuning a look in one
216
+ // and shipping the other. The fbm below still uses snoise: it never sees the
217
+ // drift, so it never needed to tile, and leaving it alone keeps the colour
218
+ // masses' texture exactly as it was.
219
+ vec2 curl(vec2 p, float tile) {
220
+ // Finite-difference step: small enough to approximate a derivative, large
221
+ // enough that the noise's own float precision doesn't swamp the difference
222
+ // between the two samples.
150
223
  float eps = 0.05;
151
- float dx = (snoise(p + vec2(eps, 0.0)) - snoise(p - vec2(eps, 0.0))) / (2.0 * eps);
152
- float dy = (snoise(p + vec2(0.0, eps)) - snoise(p - vec2(0.0, eps))) / (2.0 * eps);
224
+ vec2 rep = vec2(tile);
225
+ float dx = (pnoise(p + vec2(eps, 0.0), rep) - pnoise(p - vec2(eps, 0.0), rep)) / (2.0 * eps);
226
+ float dy = (pnoise(p + vec2(0.0, eps), rep) - pnoise(p - vec2(0.0, eps), rep)) / (2.0 * eps);
153
227
  return vec2(dy, -dx);
154
228
  }
155
229
 
@@ -162,7 +236,37 @@ void main() {
162
236
  // directly would. At 0.05 (that earlier prototype's rate) the whole
163
237
  // composition reorganized every ~3 seconds, measured as more pixel change
164
238
  // over 3.5s than the beam prototype showed over 7.5s.
165
- float drift = u_time * 0.025;
239
+ //
240
+ // Curl field frequency, relative to the fbm's. Below 1.0 the currents are
241
+ // LARGER than the colour masses they carry, which is the look: sampled at
242
+ // the same frequency each mass sits inside its own little eddy, the
243
+ // advection only roughens mass edges, and the result is indistinguishable
244
+ // from a plain warped fbm.
245
+ //
246
+ // u_curl replaces what was a hardcoded 0.55, and its default is 1.05
247
+ // because the potential is pnoise now. Classic Perlin's lattice is a unit
248
+ // grid while simplex's skewed cells are roughly 0.7 units, so the same
249
+ // input scale yields visibly coarser features -- fewer closed eddies. 1.05
250
+ // was matched by eye against a reference render of the pre-pnoise look.
251
+ //
252
+ // The visible frame spans curlScale units of noise, so the tile has to be
253
+ // at least twice that or the field repeats inside a single frame, which
254
+ // looks like wallpaper; ceil keeps it integral, which pnoise requires.
255
+ float curlScale = u_scale * u_curl;
256
+ float tile = max(2.0, ceil(curlScale * 2.0));
257
+
258
+ // Straight-line travel through a tiling field: the direction never changes,
259
+ // so this reads as continuous flow rather than the sway a circular path
260
+ // gives. One tile per loop, hence rate = tile/u_loop -- a short loop flows
261
+ // fast and a long one slowly, which is the price of the straight line.
262
+ // At the default scale that is tile 2 over a 60s loop = 0.033/sec, near
263
+ // enough to the hand-tuned 0.025*sqrt(2) that the look is preserved.
264
+ //
265
+ // vec2(1.0) -- not vec2(1.0, 0.0) -- because this drift was originally a
266
+ // SCALAR added to a vec2 coordinate, i.e. a diagonal translation.
267
+ // loopTravel takes its non-looping speed from |dir|, so dropping the
268
+ // diagonal here would quietly slow the unlooped look down by 30%.
269
+ vec2 drift = loopTravel(0.025, vec2(1.0), tile);
166
270
 
167
271
  // Advect the sample point along the curl field in 3 FIXED steps (written
168
272
  // out explicitly rather than a variable-length loop, which risks the
@@ -172,19 +276,18 @@ void main() {
172
276
  // the NUMBER of steps that turns advection into rotation: one step is a
173
277
  // plain directional shove, and at two the point still travels an almost
174
278
  // straight chord. The third is where it curves enough to close visible
175
- // eddies, which is the whole point of the look. Step gain is dropped from
176
- // 0.08 to 0.055 to keep the total travel about where it was.
279
+ // eddies, which is the whole point of the look.
177
280
  //
178
- // The curl field is sampled at 0.55x the fbm's frequency, i.e. the
179
- // currents are deliberately LARGER than the colour masses they carry.
180
- // Sampled at the same frequency (as it was) each mass sat inside its own
181
- // little eddy, so the advection only roughened mass edges and the result
182
- // was indistinguishable from a plain warped fbm.
183
- float curlScale = u_scale * 0.55;
281
+ // Step gain 0.19, up from the 0.055 that was tuned against a simplex
282
+ // potential. pnoise carries shallower gradients, so the old gain advected
283
+ // the point far too little and the eddies stopped closing -- the frame went
284
+ // to soft diagonal bands. The gain absorbs that difference rather than the
285
+ // default of u_drift, which stays at 0.5 in its published 0-1 range so the
286
+ // knob means the same thing it always did.
184
287
  vec2 advected = uv;
185
- advected += curl(advected * curlScale + u_seed + drift) * u_drift * 0.055;
186
- advected += curl(advected * curlScale + u_seed + drift) * u_drift * 0.055;
187
- advected += curl(advected * curlScale + u_seed + drift) * u_drift * 0.055;
288
+ advected += curl(advected * curlScale + u_seed + drift, tile) * u_drift * 0.19;
289
+ advected += curl(advected * curlScale + u_seed + drift, tile) * u_drift * 0.19;
290
+ advected += curl(advected * curlScale + u_seed + drift, tile) * u_drift * 0.19;
188
291
 
189
292
  float t = fbm2(advected * u_scale + u_seed);
190
293
 
@@ -234,6 +337,14 @@ const flow = {
234
337
  step: .05,
235
338
  default: 1.7
236
339
  },
340
+ {
341
+ key: "curl",
342
+ label: "Curl",
343
+ min: .3,
344
+ max: 1.6,
345
+ step: .05,
346
+ default: 1.05
347
+ },
237
348
  {
238
349
  key: "drift",
239
350
  label: "Drift",
@@ -262,7 +373,8 @@ const flow = {
262
373
  randomParams(rand) {
263
374
  return {
264
375
  scale: .6 + rand() * 2,
265
- drift: rand() * 1,
376
+ curl: .6 + rand() * .7000000000000001,
377
+ drift: .25 + rand() * .75,
266
378
  openness: rand() * 1,
267
379
  grain: .08
268
380
  };
@@ -335,7 +447,14 @@ void main() {
335
447
  // Same slow crawl rate as the siblings. It slides the bend's sample window
336
448
  // along the noise slice, so the whole beam sways -- the S migrates -- with
337
449
  // no other motion source needed for the silhouette.
338
- float drift = u_time * 0.02;
450
+ //
451
+ // Along x only (vec2(1.0, 0.0)) when not looping, matching the original
452
+ // scalar offset. When looping, loopDrift bends that into a circle, so the
453
+ // sample window also travels a little in y -- i.e. onto neighbouring noise
454
+ // slices. That reads as the bend MORPHING as well as migrating, which is
455
+ // if anything richer than pure translation, and it is what avoids the
456
+ // visible back-and-forth reversal a one-axis sine would give.
457
+ vec2 drift = loopDrift(0.02, vec2(1.0, 0.0));
339
458
 
340
459
  // Where the beam sits across the frame. One static seed term (placement)
341
460
  // plus the animated bend. Placement is held to 50% of crossHalf so the
@@ -343,14 +462,21 @@ void main() {
343
462
  // locally, and the clamp stops the sum at 75% so the worst seed still
344
463
  // keeps the core inside the frame instead of showing only its halo.
345
464
  float off0 = snoise(vec2(seedRow, 3.7));
346
- float bend = snoise(vec2(sn * (0.55 * u_scale) + drift, seedRow));
465
+ float bend = snoise(vec2(sn * (0.55 * u_scale), seedRow) + drift);
347
466
  float c = crossHalf * clamp(0.5 * off0 + 0.45 * bend, -0.75, 0.75);
348
467
 
349
468
  // Breathing: the width swells ~10% over a ~57s cycle, phase-shifted along
350
469
  // the beam (the sn * 2.0 term) so it travels as a slow peristaltic wave
351
470
  // rather than the whole beam pulsing in lockstep, which read as a strobe
352
471
  // precursor even at this amplitude.
353
- float w = u_width * (1.0 + 0.10 * sin(u_time * 0.11 + sn * 2.0 + u_seed));
472
+ //
473
+ // loopFreq snaps 0.11 to a whole number of cycles per loop. Loops of ~29s
474
+ // and up get one swell per cycle (at 57s that IS 0.11, unchanged). Shorter
475
+ // loops round to zero and the swell holds still at its along-beam phase --
476
+ // the deliberate choice, since the alternative at e.g. an 8s loop is a
477
+ // frequency 7x the tuned rate, i.e. exactly the strobe this amplitude was
478
+ // picked to avoid.
479
+ float w = u_width * (1.0 + 0.10 * sin(loopFreq(0.11) * u_time + sn * 2.0 + u_seed));
354
480
 
355
481
  // Signed cross distance in units of the beam's own width. Everything
356
482
  // profile-shaped below is a function of this one number.
@@ -393,8 +519,10 @@ void main() {
393
519
  // ridge lines blew up into jagged chevron kinks. Absolute sampling keeps
394
520
  // strands hair-thin at every width (26.0 = the old 2.6/nd density at the
395
521
  // original 0.1 default, preserving the approved look there).
396
- float crawl = u_time * 0.05;
397
- float fil = snoise(vec2(sn * (0.9 * u_scale) - crawl, (q - c) * 26.0 + seedRow * 1.7));
522
+ // Negative dir keeps the pre-loop sign (the coordinate subtracted crawl),
523
+ // so the strands still travel the same way along the beam.
524
+ vec2 crawl = loopDrift(0.05, vec2(-1.0, 0.0));
525
+ float fil = snoise(vec2(sn * (0.9 * u_scale), (q - c) * 26.0 + seedRow * 1.7) + crawl);
398
526
 
399
527
  // Holographic banding: the same field nudges the ramp position inside the
400
528
  // core, so colour bands streak lengthwise through the beam (the foil-like
@@ -766,11 +894,18 @@ function clamp255(v) {
766
894
  * `u_time` and `u_seed` arrive pre-modded (see renderAt below) so that a
767
895
  * shader doing `sin(u_time * freq)` never loses float32 precision from a
768
896
  * time value that has grown large over a long-running session.
897
+ *
898
+ * Two more helpers exist so shaders can be made seamlessly loopable without
899
+ * each one reinventing the maths — see loopDrift/loopFreq below. Any shader
900
+ * whose only time dependence goes through those two (plus grain(), which
901
+ * loops for free because u_time itself wraps at the period) is exactly
902
+ * periodic with period `u_loop`.
769
903
  */
770
904
  const BASE_UNIFORMS = `precision highp float;
771
905
  uniform vec2 u_resolution; // canvas pixels
772
- uniform float u_time; // seconds, pre-modded to [0,1000)
906
+ uniform float u_time; // seconds, pre-modded to [0,1000), or to [0,u_loop) when looping
773
907
  uniform float u_seed; // pre-modded to [0,100)
908
+ uniform float u_loop; // seconds per seamless cycle; 0 = never repeat
774
909
  uniform sampler2D u_palette; // 1024x1 OKLCh-interpolated ramp
775
910
  varying vec2 v_uv; // 0-1 quad UV
776
911
  // World-space UV: cover-fit a fixed 1000x562.5 world so pattern density
@@ -786,6 +921,77 @@ vec2 worldUv() {
786
921
  vec3 palette(float t) {
787
922
  return texture2D(u_palette, vec2(clamp(t, 0.0, 1.0), 0.5)).rgb;
788
923
  }
924
+
925
+ const float TAU = 6.2831853;
926
+
927
+ // Time-varying offset for a noise sample coordinate.
928
+ //
929
+ // Not looping (u_loop == 0): a plain linear translation, dir * rate * t.
930
+ // This is the arithmetic the shaders used before looping existed, so the
931
+ // default path is bit-identical to the pre-loop renderer.
932
+ //
933
+ // Looping: the same walk, bent into a closed circle of circumference
934
+ // rate * |dir| * u_loop. Because the offset returns to exactly where it
935
+ // started after u_loop seconds, every value derived from it does too --
936
+ // that is the whole loop. A circle (rather than, say, a sine ping-pong on
937
+ // one axis) is what keeps this invisible: the drift DIRECTION rotates
938
+ // smoothly through 360 degrees over the cycle and never reverses, which on
939
+ // an isotropic noise field is indistinguishable from continuing to travel
940
+ // in a straight line. The radius is set from arc length, so the sampled
941
+ // point covers the same distance per second whether looping or not and the
942
+ // animation runs at an identical apparent speed either way.
943
+ //
944
+ // |dir| matters and is easy to get wrong: a shader adding a scalar drift to
945
+ // both components of a vec2 is translating along the diagonal at rate*sqrt(2),
946
+ // not at rate. Passing dir un-normalized lets each call site keep its
947
+ // original speed exactly.
948
+ //
949
+ // Radii stay small (rate 0.05 over a 60s loop gives r ~ 0.48, well under the
950
+ // noise field's ~1-unit feature size), so this never approaches the
951
+ // float-precision ceiling noise.ts warns about.
952
+ vec2 loopDrift(float rate, vec2 dir) {
953
+ if (u_loop <= 0.0) return dir * (rate * u_time);
954
+ float phase = TAU * u_time / u_loop;
955
+ return vec2(cos(phase), sin(phase)) * (rate * length(dir) * u_loop / TAU);
956
+ }
957
+
958
+ // Straight-line travel that still loops, for shaders sampling a noise field
959
+ // that TILES with period "tile" (see PERIODIC_2D in shaders/noise.ts).
960
+ //
961
+ // This is the better half of loopDrift, and the difference is the whole
962
+ // reason it exists. loopDrift has to curve, because a simplex field never
963
+ // repeats, so the only way back to the start is to come around -- and a
964
+ // drift direction that rotates through 360 degrees per cycle is perceived as
965
+ // the composition swaying back and forth. Against a tiling field the path can
966
+ // stay perfectly straight: travel exactly one tile and the field you are
967
+ // standing in is bit-identical to the one you left. The motion never turns,
968
+ // so it reads as continuous flow.
969
+ //
970
+ // The cost is that speed is no longer free. Travel per cycle is pinned to the
971
+ // tile size, so rate becomes tile/u_loop: a short loop flows fast, a long one
972
+ // slowly. The tile cannot simply be shrunk to compensate, because a tile
973
+ // narrower than the visible frame means the field repeats WITHIN one frame,
974
+ // which is a far worse artifact than any of this. Callers should size it
975
+ // from their own sampling frequency.
976
+ vec2 loopTravel(float rate, vec2 dir, float tile) {
977
+ if (u_loop <= 0.0) return dir * (rate * u_time);
978
+ return dir * (tile * u_time / u_loop);
979
+ }
980
+
981
+ // Snaps an angular frequency to a whole number of cycles per loop, which is
982
+ // what makes sin(loopFreq(w) * u_time + anything) exactly periodic.
983
+ //
984
+ // Rounding to ZERO is deliberate and is the useful case, not a degenerate
985
+ // one: when the loop is shorter than about half the oscillation's natural
986
+ // period, the nearest legal frequency would be far faster than the shader
987
+ // was tuned for, turning a slow swell into a throb. Returning 0 instead
988
+ // freezes the oscillation at its per-pixel phase, so a spatially-varying
989
+ // term stays spatially varying and simply stops animating -- a far less
990
+ // visible change than speeding it up.
991
+ float loopFreq(float w) {
992
+ if (u_loop <= 0.0) return w;
993
+ return TAU * floor(w * u_loop / TAU + 0.5) / u_loop;
994
+ }
789
995
  `;
790
996
  /** Fullscreen-triangle-strip vertex shader. Four vertices covering [-1,1]^2,
791
997
  * with v_uv carrying the matching 0-1 UV for the fragment shader. */
@@ -848,11 +1054,19 @@ function linkProgram(gl, vertexShader, fragmentShader) {
848
1054
  }
849
1055
  return program;
850
1056
  }
1057
+ /** Normalizes a loop period to the "off" sentinel the GLSL side expects.
1058
+ * Non-finite and non-positive values all mean "don't loop", so callers can
1059
+ * pass through user input without pre-validating it. */
1060
+ function normalizeLoop(seconds) {
1061
+ if (seconds === void 0 || !Number.isFinite(seconds) || seconds <= 0) return 0;
1062
+ return seconds;
1063
+ }
851
1064
  function createRenderer(opts) {
852
1065
  const { canvas, shader } = opts;
853
1066
  let colors = opts.colors;
854
1067
  let params = opts.params;
855
1068
  const seed = opts.seed;
1069
+ let loopSeconds = normalizeLoop(opts.loopSeconds);
856
1070
  const glOrNull = canvas.getContext("webgl", {
857
1071
  preserveDrawingBuffer: true,
858
1072
  antialias: false
@@ -886,6 +1100,7 @@ function createRenderer(opts) {
886
1100
  const resolutionLoc = gl.getUniformLocation(program, "u_resolution");
887
1101
  const timeLoc = gl.getUniformLocation(program, "u_time");
888
1102
  const seedLoc = gl.getUniformLocation(program, "u_seed");
1103
+ const loopLoc = gl.getUniformLocation(program, "u_loop");
889
1104
  const paletteLoc = gl.getUniformLocation(program, "u_palette");
890
1105
  const paramLocs = /* @__PURE__ */ new Map();
891
1106
  for (const paramDef of shader.params) paramLocs.set(paramDef.key, gl.getUniformLocation(program, `u_${paramDef.key}`));
@@ -901,9 +1116,10 @@ function createRenderer(opts) {
901
1116
  gl.viewport(0, 0, canvas.width, canvas.height);
902
1117
  gl.useProgram(program);
903
1118
  gl.uniform2f(resolutionLoc, canvas.width, canvas.height);
904
- const timeSec = floorMod(timeMs / 1e3, 1e3);
1119
+ const timeSec = floorMod(timeMs / 1e3, loopSeconds > 0 ? loopSeconds : 1e3);
905
1120
  gl.uniform1f(timeLoc, timeSec);
906
1121
  gl.uniform1f(seedLoc, floorMod(seed, 100));
1122
+ gl.uniform1f(loopLoc, loopSeconds);
907
1123
  gl.activeTexture(gl.TEXTURE0);
908
1124
  gl.bindTexture(gl.TEXTURE_2D, paletteTexture);
909
1125
  gl.uniform1i(paletteLoc, 0);
@@ -920,6 +1136,9 @@ function createRenderer(opts) {
920
1136
  function setParams(next) {
921
1137
  params = next;
922
1138
  }
1139
+ function setLoopSeconds(seconds) {
1140
+ loopSeconds = normalizeLoop(seconds);
1141
+ }
923
1142
  function resize(width, height) {
924
1143
  canvas.width = width;
925
1144
  canvas.height = height;
@@ -934,6 +1153,7 @@ function createRenderer(opts) {
934
1153
  renderAt,
935
1154
  setColors,
936
1155
  setParams,
1156
+ setLoopSeconds,
937
1157
  resize,
938
1158
  dispose
939
1159
  };
@@ -986,7 +1206,8 @@ function mountGradient(container, opts) {
986
1206
  shader: def,
987
1207
  colors,
988
1208
  params,
989
- seed
1209
+ seed,
1210
+ loopSeconds: opts.loopSeconds
990
1211
  });
991
1212
  let clockMs = 0;
992
1213
  let epoch = performance.now();
@@ -1043,6 +1264,10 @@ function mountGradient(container, opts) {
1043
1264
  renderer.setParams(params);
1044
1265
  if (!playing) renderOnce();
1045
1266
  },
1267
+ setLoopSeconds(seconds) {
1268
+ renderer.setLoopSeconds(seconds);
1269
+ if (!playing) renderOnce();
1270
+ },
1046
1271
  setSpeed(next) {
1047
1272
  if (playing && speed !== 0) clockMs = (performance.now() - epoch) * speed;
1048
1273
  speed = next;
@@ -1102,7 +1327,8 @@ function renderGradientFrame(opts) {
1102
1327
  shader: def,
1103
1328
  colors: opts.colors,
1104
1329
  params: resolveParams(def, opts.params),
1105
- seed: opts.seed ?? 0
1330
+ seed: opts.seed ?? 0,
1331
+ loopSeconds: opts.loopSeconds
1106
1332
  });
1107
1333
  renderer.renderAt(opts.timeMs ?? 0);
1108
1334
  const gl = canvas.getContext("webgl");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instantshader",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Animated WebGL gradient shaders. Zero dependencies.",
5
5
  "type": "module",
6
6
  "license": "MIT",