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 +48 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +259 -33
- 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
|
|
@@ -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
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
152
|
-
float
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
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.
|
|
186
|
-
advected += curl(advected * curlScale + u_seed + drift) * u_drift * 0.
|
|
187
|
-
advected += curl(advected * curlScale + u_seed + drift) * u_drift * 0.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
397
|
-
|
|
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");
|