instantshader 0.1.0 → 0.2.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
@@ -134,22 +197,32 @@ uniform float u_openness;
134
197
  uniform float u_grain;
135
198
 
136
199
  ${SIMPLEX_2D}
200
+ ${PERIODIC_2D}
137
201
  ${FBM}
138
202
  ${SHAPE}
139
203
  ${GRAIN}
140
204
 
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.
205
+ // Curl of a scalar potential field: the finite-difference gradient, rotated
206
+ // 90 degrees -- (dPsi/dy, -dPsi/dx) instead of (dPsi/dx, dPsi/dy). A rotated
207
+ // gradient is always divergence-free, which is the whole trick: advecting a
208
+ // point along it produces swirling motion with nothing to make it converge
209
+ // or diverge, unlike advecting along the gradient itself.
210
+ //
211
+ // The potential is pnoise, not snoise, and it is pnoise in BOTH looping and
212
+ // non-looping modes on purpose. A tiling field is what lets the drift travel
213
+ // in a straight line and still return (see loopTravel), and having the two
214
+ // modes disagree about which noise they use would mean tuning a look in one
215
+ // and shipping the other. The fbm below still uses snoise: it never sees the
216
+ // drift, so it never needed to tile, and leaving it alone keeps the colour
217
+ // masses' texture exactly as it was.
218
+ vec2 curl(vec2 p, float tile) {
219
+ // Finite-difference step: small enough to approximate a derivative, large
220
+ // enough that the noise's own float precision doesn't swamp the difference
221
+ // between the two samples.
150
222
  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);
223
+ vec2 rep = vec2(tile);
224
+ float dx = (pnoise(p + vec2(eps, 0.0), rep) - pnoise(p - vec2(eps, 0.0), rep)) / (2.0 * eps);
225
+ float dy = (pnoise(p + vec2(0.0, eps), rep) - pnoise(p - vec2(0.0, eps), rep)) / (2.0 * eps);
153
226
  return vec2(dy, -dx);
154
227
  }
155
228
 
@@ -162,7 +235,26 @@ void main() {
162
235
  // directly would. At 0.05 (that earlier prototype's rate) the whole
163
236
  // composition reorganized every ~3 seconds, measured as more pixel change
164
237
  // over 3.5s than the beam prototype showed over 7.5s.
165
- float drift = u_time * 0.025;
238
+ //
239
+ // The curl field is sampled at 0.55x the fbm's frequency (see below), so
240
+ // the visible frame spans curlScale units of noise. The tile has to be at
241
+ // least twice that or the field repeats inside a single frame, which looks
242
+ // like wallpaper; ceil keeps it integral, which pnoise requires.
243
+ float curlScale = u_scale * 0.55;
244
+ float tile = max(2.0, ceil(curlScale * 2.0));
245
+
246
+ // Straight-line travel through a tiling field: the direction never changes,
247
+ // so this reads as continuous flow rather than the sway a circular path
248
+ // gives. One tile per loop, hence rate = tile/u_loop -- a short loop flows
249
+ // fast and a long one slowly, which is the price of the straight line.
250
+ // At the default scale that is tile 2 over a 60s loop = 0.033/sec, near
251
+ // enough to the hand-tuned 0.025*sqrt(2) that the look is preserved.
252
+ //
253
+ // vec2(1.0) -- not vec2(1.0, 0.0) -- because this drift was originally a
254
+ // SCALAR added to a vec2 coordinate, i.e. a diagonal translation.
255
+ // loopTravel takes its non-looping speed from |dir|, so dropping the
256
+ // diagonal here would quietly slow the unlooped look down by 30%.
257
+ vec2 drift = loopTravel(0.025, vec2(1.0), tile);
166
258
 
167
259
  // Advect the sample point along the curl field in 3 FIXED steps (written
168
260
  // out explicitly rather than a variable-length loop, which risks the
@@ -180,11 +272,10 @@ void main() {
180
272
  // Sampled at the same frequency (as it was) each mass sat inside its own
181
273
  // little eddy, so the advection only roughened mass edges and the result
182
274
  // was indistinguishable from a plain warped fbm.
183
- float curlScale = u_scale * 0.55;
184
275
  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;
276
+ advected += curl(advected * curlScale + u_seed + drift, tile) * u_drift * 0.055;
277
+ advected += curl(advected * curlScale + u_seed + drift, tile) * u_drift * 0.055;
278
+ advected += curl(advected * curlScale + u_seed + drift, tile) * u_drift * 0.055;
188
279
 
189
280
  float t = fbm2(advected * u_scale + u_seed);
190
281
 
@@ -335,7 +426,14 @@ void main() {
335
426
  // Same slow crawl rate as the siblings. It slides the bend's sample window
336
427
  // along the noise slice, so the whole beam sways -- the S migrates -- with
337
428
  // no other motion source needed for the silhouette.
338
- float drift = u_time * 0.02;
429
+ //
430
+ // Along x only (vec2(1.0, 0.0)) when not looping, matching the original
431
+ // scalar offset. When looping, loopDrift bends that into a circle, so the
432
+ // sample window also travels a little in y -- i.e. onto neighbouring noise
433
+ // slices. That reads as the bend MORPHING as well as migrating, which is
434
+ // if anything richer than pure translation, and it is what avoids the
435
+ // visible back-and-forth reversal a one-axis sine would give.
436
+ vec2 drift = loopDrift(0.02, vec2(1.0, 0.0));
339
437
 
340
438
  // Where the beam sits across the frame. One static seed term (placement)
341
439
  // plus the animated bend. Placement is held to 50% of crossHalf so the
@@ -343,14 +441,21 @@ void main() {
343
441
  // locally, and the clamp stops the sum at 75% so the worst seed still
344
442
  // keeps the core inside the frame instead of showing only its halo.
345
443
  float off0 = snoise(vec2(seedRow, 3.7));
346
- float bend = snoise(vec2(sn * (0.55 * u_scale) + drift, seedRow));
444
+ float bend = snoise(vec2(sn * (0.55 * u_scale), seedRow) + drift);
347
445
  float c = crossHalf * clamp(0.5 * off0 + 0.45 * bend, -0.75, 0.75);
348
446
 
349
447
  // Breathing: the width swells ~10% over a ~57s cycle, phase-shifted along
350
448
  // the beam (the sn * 2.0 term) so it travels as a slow peristaltic wave
351
449
  // rather than the whole beam pulsing in lockstep, which read as a strobe
352
450
  // precursor even at this amplitude.
353
- float w = u_width * (1.0 + 0.10 * sin(u_time * 0.11 + sn * 2.0 + u_seed));
451
+ //
452
+ // loopFreq snaps 0.11 to a whole number of cycles per loop. Loops of ~29s
453
+ // and up get one swell per cycle (at 57s that IS 0.11, unchanged). Shorter
454
+ // loops round to zero and the swell holds still at its along-beam phase --
455
+ // the deliberate choice, since the alternative at e.g. an 8s loop is a
456
+ // frequency 7x the tuned rate, i.e. exactly the strobe this amplitude was
457
+ // picked to avoid.
458
+ float w = u_width * (1.0 + 0.10 * sin(loopFreq(0.11) * u_time + sn * 2.0 + u_seed));
354
459
 
355
460
  // Signed cross distance in units of the beam's own width. Everything
356
461
  // profile-shaped below is a function of this one number.
@@ -393,8 +498,10 @@ void main() {
393
498
  // ridge lines blew up into jagged chevron kinks. Absolute sampling keeps
394
499
  // strands hair-thin at every width (26.0 = the old 2.6/nd density at the
395
500
  // 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));
501
+ // Negative dir keeps the pre-loop sign (the coordinate subtracted crawl),
502
+ // so the strands still travel the same way along the beam.
503
+ vec2 crawl = loopDrift(0.05, vec2(-1.0, 0.0));
504
+ float fil = snoise(vec2(sn * (0.9 * u_scale), (q - c) * 26.0 + seedRow * 1.7) + crawl);
398
505
 
399
506
  // Holographic banding: the same field nudges the ramp position inside the
400
507
  // core, so colour bands streak lengthwise through the beam (the foil-like
@@ -766,11 +873,18 @@ function clamp255(v) {
766
873
  * `u_time` and `u_seed` arrive pre-modded (see renderAt below) so that a
767
874
  * shader doing `sin(u_time * freq)` never loses float32 precision from a
768
875
  * time value that has grown large over a long-running session.
876
+ *
877
+ * Two more helpers exist so shaders can be made seamlessly loopable without
878
+ * each one reinventing the maths — see loopDrift/loopFreq below. Any shader
879
+ * whose only time dependence goes through those two (plus grain(), which
880
+ * loops for free because u_time itself wraps at the period) is exactly
881
+ * periodic with period `u_loop`.
769
882
  */
770
883
  const BASE_UNIFORMS = `precision highp float;
771
884
  uniform vec2 u_resolution; // canvas pixels
772
- uniform float u_time; // seconds, pre-modded to [0,1000)
885
+ uniform float u_time; // seconds, pre-modded to [0,1000), or to [0,u_loop) when looping
773
886
  uniform float u_seed; // pre-modded to [0,100)
887
+ uniform float u_loop; // seconds per seamless cycle; 0 = never repeat
774
888
  uniform sampler2D u_palette; // 1024x1 OKLCh-interpolated ramp
775
889
  varying vec2 v_uv; // 0-1 quad UV
776
890
  // World-space UV: cover-fit a fixed 1000x562.5 world so pattern density
@@ -786,6 +900,77 @@ vec2 worldUv() {
786
900
  vec3 palette(float t) {
787
901
  return texture2D(u_palette, vec2(clamp(t, 0.0, 1.0), 0.5)).rgb;
788
902
  }
903
+
904
+ const float TAU = 6.2831853;
905
+
906
+ // Time-varying offset for a noise sample coordinate.
907
+ //
908
+ // Not looping (u_loop == 0): a plain linear translation, dir * rate * t.
909
+ // This is the arithmetic the shaders used before looping existed, so the
910
+ // default path is bit-identical to the pre-loop renderer.
911
+ //
912
+ // Looping: the same walk, bent into a closed circle of circumference
913
+ // rate * |dir| * u_loop. Because the offset returns to exactly where it
914
+ // started after u_loop seconds, every value derived from it does too --
915
+ // that is the whole loop. A circle (rather than, say, a sine ping-pong on
916
+ // one axis) is what keeps this invisible: the drift DIRECTION rotates
917
+ // smoothly through 360 degrees over the cycle and never reverses, which on
918
+ // an isotropic noise field is indistinguishable from continuing to travel
919
+ // in a straight line. The radius is set from arc length, so the sampled
920
+ // point covers the same distance per second whether looping or not and the
921
+ // animation runs at an identical apparent speed either way.
922
+ //
923
+ // |dir| matters and is easy to get wrong: a shader adding a scalar drift to
924
+ // both components of a vec2 is translating along the diagonal at rate*sqrt(2),
925
+ // not at rate. Passing dir un-normalized lets each call site keep its
926
+ // original speed exactly.
927
+ //
928
+ // Radii stay small (rate 0.05 over a 60s loop gives r ~ 0.48, well under the
929
+ // noise field's ~1-unit feature size), so this never approaches the
930
+ // float-precision ceiling noise.ts warns about.
931
+ vec2 loopDrift(float rate, vec2 dir) {
932
+ if (u_loop <= 0.0) return dir * (rate * u_time);
933
+ float phase = TAU * u_time / u_loop;
934
+ return vec2(cos(phase), sin(phase)) * (rate * length(dir) * u_loop / TAU);
935
+ }
936
+
937
+ // Straight-line travel that still loops, for shaders sampling a noise field
938
+ // that TILES with period "tile" (see PERIODIC_2D in shaders/noise.ts).
939
+ //
940
+ // This is the better half of loopDrift, and the difference is the whole
941
+ // reason it exists. loopDrift has to curve, because a simplex field never
942
+ // repeats, so the only way back to the start is to come around -- and a
943
+ // drift direction that rotates through 360 degrees per cycle is perceived as
944
+ // the composition swaying back and forth. Against a tiling field the path can
945
+ // stay perfectly straight: travel exactly one tile and the field you are
946
+ // standing in is bit-identical to the one you left. The motion never turns,
947
+ // so it reads as continuous flow.
948
+ //
949
+ // The cost is that speed is no longer free. Travel per cycle is pinned to the
950
+ // tile size, so rate becomes tile/u_loop: a short loop flows fast, a long one
951
+ // slowly. The tile cannot simply be shrunk to compensate, because a tile
952
+ // narrower than the visible frame means the field repeats WITHIN one frame,
953
+ // which is a far worse artifact than any of this. Callers should size it
954
+ // from their own sampling frequency.
955
+ vec2 loopTravel(float rate, vec2 dir, float tile) {
956
+ if (u_loop <= 0.0) return dir * (rate * u_time);
957
+ return dir * (tile * u_time / u_loop);
958
+ }
959
+
960
+ // Snaps an angular frequency to a whole number of cycles per loop, which is
961
+ // what makes sin(loopFreq(w) * u_time + anything) exactly periodic.
962
+ //
963
+ // Rounding to ZERO is deliberate and is the useful case, not a degenerate
964
+ // one: when the loop is shorter than about half the oscillation's natural
965
+ // period, the nearest legal frequency would be far faster than the shader
966
+ // was tuned for, turning a slow swell into a throb. Returning 0 instead
967
+ // freezes the oscillation at its per-pixel phase, so a spatially-varying
968
+ // term stays spatially varying and simply stops animating -- a far less
969
+ // visible change than speeding it up.
970
+ float loopFreq(float w) {
971
+ if (u_loop <= 0.0) return w;
972
+ return TAU * floor(w * u_loop / TAU + 0.5) / u_loop;
973
+ }
789
974
  `;
790
975
  /** Fullscreen-triangle-strip vertex shader. Four vertices covering [-1,1]^2,
791
976
  * with v_uv carrying the matching 0-1 UV for the fragment shader. */
@@ -848,11 +1033,19 @@ function linkProgram(gl, vertexShader, fragmentShader) {
848
1033
  }
849
1034
  return program;
850
1035
  }
1036
+ /** Normalizes a loop period to the "off" sentinel the GLSL side expects.
1037
+ * Non-finite and non-positive values all mean "don't loop", so callers can
1038
+ * pass through user input without pre-validating it. */
1039
+ function normalizeLoop(seconds) {
1040
+ if (seconds === void 0 || !Number.isFinite(seconds) || seconds <= 0) return 0;
1041
+ return seconds;
1042
+ }
851
1043
  function createRenderer(opts) {
852
1044
  const { canvas, shader } = opts;
853
1045
  let colors = opts.colors;
854
1046
  let params = opts.params;
855
1047
  const seed = opts.seed;
1048
+ let loopSeconds = normalizeLoop(opts.loopSeconds);
856
1049
  const glOrNull = canvas.getContext("webgl", {
857
1050
  preserveDrawingBuffer: true,
858
1051
  antialias: false
@@ -886,6 +1079,7 @@ function createRenderer(opts) {
886
1079
  const resolutionLoc = gl.getUniformLocation(program, "u_resolution");
887
1080
  const timeLoc = gl.getUniformLocation(program, "u_time");
888
1081
  const seedLoc = gl.getUniformLocation(program, "u_seed");
1082
+ const loopLoc = gl.getUniformLocation(program, "u_loop");
889
1083
  const paletteLoc = gl.getUniformLocation(program, "u_palette");
890
1084
  const paramLocs = /* @__PURE__ */ new Map();
891
1085
  for (const paramDef of shader.params) paramLocs.set(paramDef.key, gl.getUniformLocation(program, `u_${paramDef.key}`));
@@ -901,9 +1095,10 @@ function createRenderer(opts) {
901
1095
  gl.viewport(0, 0, canvas.width, canvas.height);
902
1096
  gl.useProgram(program);
903
1097
  gl.uniform2f(resolutionLoc, canvas.width, canvas.height);
904
- const timeSec = floorMod(timeMs / 1e3, 1e3);
1098
+ const timeSec = floorMod(timeMs / 1e3, loopSeconds > 0 ? loopSeconds : 1e3);
905
1099
  gl.uniform1f(timeLoc, timeSec);
906
1100
  gl.uniform1f(seedLoc, floorMod(seed, 100));
1101
+ gl.uniform1f(loopLoc, loopSeconds);
907
1102
  gl.activeTexture(gl.TEXTURE0);
908
1103
  gl.bindTexture(gl.TEXTURE_2D, paletteTexture);
909
1104
  gl.uniform1i(paletteLoc, 0);
@@ -920,6 +1115,9 @@ function createRenderer(opts) {
920
1115
  function setParams(next) {
921
1116
  params = next;
922
1117
  }
1118
+ function setLoopSeconds(seconds) {
1119
+ loopSeconds = normalizeLoop(seconds);
1120
+ }
923
1121
  function resize(width, height) {
924
1122
  canvas.width = width;
925
1123
  canvas.height = height;
@@ -934,6 +1132,7 @@ function createRenderer(opts) {
934
1132
  renderAt,
935
1133
  setColors,
936
1134
  setParams,
1135
+ setLoopSeconds,
937
1136
  resize,
938
1137
  dispose
939
1138
  };
@@ -986,7 +1185,8 @@ function mountGradient(container, opts) {
986
1185
  shader: def,
987
1186
  colors,
988
1187
  params,
989
- seed
1188
+ seed,
1189
+ loopSeconds: opts.loopSeconds
990
1190
  });
991
1191
  let clockMs = 0;
992
1192
  let epoch = performance.now();
@@ -1043,6 +1243,10 @@ function mountGradient(container, opts) {
1043
1243
  renderer.setParams(params);
1044
1244
  if (!playing) renderOnce();
1045
1245
  },
1246
+ setLoopSeconds(seconds) {
1247
+ renderer.setLoopSeconds(seconds);
1248
+ if (!playing) renderOnce();
1249
+ },
1046
1250
  setSpeed(next) {
1047
1251
  if (playing && speed !== 0) clockMs = (performance.now() - epoch) * speed;
1048
1252
  speed = next;
@@ -1102,7 +1306,8 @@ function renderGradientFrame(opts) {
1102
1306
  shader: def,
1103
1307
  colors: opts.colors,
1104
1308
  params: resolveParams(def, opts.params),
1105
- seed: opts.seed ?? 0
1309
+ seed: opts.seed ?? 0,
1310
+ loopSeconds: opts.loopSeconds
1106
1311
  });
1107
1312
  renderer.renderAt(opts.timeMs ?? 0);
1108
1313
  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.2.0",
4
4
  "description": "Animated WebGL gradient shaders. Zero dependencies.",
5
5
  "type": "module",
6
6
  "license": "MIT",