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 +48 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +230 -25
- package/package.json +1 -1
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
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
152
|
-
float
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
397
|
-
|
|
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");
|