@wave3d/core 0.6.0 → 0.8.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.
@@ -157,57 +157,7 @@ vec3 applyColorGrade(vec3 c){
157
157
  return hueShift(c, radians(uHueShift));
158
158
  }
159
159
  `;
160
- const vertexShader = `
161
- ${simplex2d}
162
-
163
- uniform float uTime, uSpeed, uSeed;
164
- uniform float uDispFreqX, uDispFreqZ, uDispAmount;
165
- uniform float uDetailFreq, uDetailAmount; // 2nd displacement octave (only read under DETAIL_OCTAVE)
166
- uniform float uTwFreqX, uTwFreqY, uTwFreqZ, uTwPowX, uTwPowY, uTwPowZ;
167
- uniform float uLoopSeconds; // seamless-loop period (only read under LOOP_MOTION)
168
-
169
- // Helix (optional). Behind HELIX so a wave without one compiles the exact same program — same
170
- // byte-identity contract as the pointer block below.
171
- #ifdef HELIX
172
- uniform float uHelixTurns; // full turns from one end of the ribbon to the other
173
- uniform float uHelixRadius; // orbit radius: carries the whole ribbon around the axis
174
- uniform float uHelixRoll; // cross-section roll, as a fraction of the turns (1 = rigid ladder)
175
- uniform float uHelixPhase; // degrees
176
- #endif
177
-
178
- varying vec2 vUv;
179
- varying vec3 vWorldPos;
180
- varying vec3 vViewDir;
181
- varying vec4 vClipPosition; // = gl_Position, for the wireframe theme's depth fade
182
-
183
- // Pointer field (optional, additive). ALL declarations here sit behind POINTER_FX so a wave with
184
- // no interaction config compiles the exact same program (JS-side uniform entries are always present
185
- // — see makeUniforms — but three only uploads uniforms the compiled program actually declares).
186
- #ifdef POINTER_FX
187
- uniform vec2 uPointer; // smoothed pointer, NDC (-1..1)
188
- uniform float uPointerActive; // presence ramp 0..1 × per-wave influence
189
- uniform float uPointerRadius; // falloff radius in NDC-y units (config radius × 2)
190
- uniform float uPointerAspect; // drawing-buffer dw/dh (circular screen falloff)
191
- uniform float uPointerAgitate;
192
- uniform float uPointerPush; // signed membrane dome at the cursor (+ repel / − attract)
193
- uniform float uPointerWake; // drag-wake trough amplitude (behind the moving cursor)
194
- uniform vec2 uPointerVel; // smoothed pointer velocity, NDC/s (drag-wake direction)
195
- // Ribbon flow: stretch the falloff along the strip's length axis so the field reaches ALONG the
196
- // ribbon rather than as a screen disc. 0 = the plain circular smoothstep (byte-identical when off).
197
- uniform float uShapeFlow;
198
- varying float vPointerFall; // falloff × presence — consumed by both fragment themes
199
- #ifdef POINTER_RIPPLES
200
- uniform vec2 uRippleOrigin[4]; // NDC
201
- uniform float uRippleAge[4]; // seconds since spawn (CPU-computed)
202
- uniform float uRippleAmp[4]; // shared 0..1 decay envelope per slot (CPU-computed; 0 = slot free)
203
- uniform float uPointerRipple; // THIS wave's ripple amplitude (scales the shared envelope)
204
- const float RIPPLE_WAVE_SPEED = 0.85; // NDC/s the ring crest travels outward
205
- const float RIPPLE_SIGMA = 0.14; // gaussian half-width of the travelling packet (NDC)
206
- const float RIPPLE_FREQ = 11.0; // oscillation within the packet (one crest + faint troughs)
207
- const float RIPPLE_MAX_R = 1.2; // reach where the crest has fully left the frame
208
- #endif
209
- #endif
210
-
160
+ const waveShapeChunk = `
211
161
  // expStep: a falloff from 1 (at x=0) toward 0, sharpness set by n. The
212
162
  // max() guards pow(0, n) (= Infinity → NaN) so negative n is safe — negative n
213
163
  // just concentrates the twist toward the OTHER end instead.
@@ -225,27 +175,15 @@ mat4 rotationMatrix(vec3 axis, float angle){
225
175
  );
226
176
  }
227
177
 
228
- void main(){
229
- vUv = uv;
230
- #ifndef LOOP_MOTION
231
- float t = uTime * uSpeed + uSeed;
232
- #endif
233
-
234
- #ifdef LOOP_MOTION
235
- // Seamless loop: rather than scrolling the noise field linearly by t (which never repeats),
236
- // sample it on a circle of radius loopR at angle loopTheta — exactly periodic with period
237
- // uLoopSeconds. The tangential speed loopR·dθ/dt equals uSpeed, so the looped motion advances
238
- // at the same rate as the linear drift, just curved into a closed orbit (it orbits rather than
239
- // drifts — the trade-off for a seamless loop, hence opt-in). uSeed offsets the phase so stacked
240
- // waves keep their relative motion while sharing the single period.
241
- float loopTheta = uTime * (6.28318530718 / uLoopSeconds) + uSeed;
242
- float loopR = uSpeed * uLoopSeconds * 0.159154943092; // = uSpeed·uLoopSeconds / (2π)
243
- vec2 loopOff = loopR * vec2(cos(loopTheta), sin(loopTheta));
244
- #endif
245
-
246
- // The base geometry is already a baked hairpin fold. On top of it we deform the
247
- // vertices: a displacement lifts Y by simplex noise of the (x,z) position, then
248
- // three axis-rotations twist the strip.
178
+ // The wave SHAPE deform, shared by the wave vertex shader (below) and the particle SHED emitter
179
+ // (particleVertexShader): a base hairpin position + its uv → the displaced / helixed / twisted /
180
+ // fanned LOCAL position, plus the three twist matrices (the pointer field reads them). Every branch
181
+ // sits behind the SAME #ifdef gates as the code it replaces, so a given compiled program is
182
+ // byte-identical to the former inline version. t / loopOff are the linear / orbit time the
183
+ // caller computed; only the one selected by LOOP_MOTION is read (the other is a dead argument).
184
+ struct WaveShape { vec3 pos; mat4 rotA; mat4 rotB; mat4 rotC; };
185
+ WaveShape waveShape(vec3 position, vec2 uv, float t, vec2 loopOff){
186
+ // Displacement lifts Y by simplex noise of the (x,z) position.
249
187
  vec3 pos = position;
250
188
  #ifdef LOOP_MOTION
251
189
  pos.y += uDispAmount * simplexNoise(vec2(pos.x * uDispFreqX, pos.z * uDispFreqZ) + loopOff);
@@ -253,9 +191,7 @@ void main(){
253
191
  pos.y += uDispAmount * simplexNoise(vec2(pos.x * uDispFreqX + t, pos.z * uDispFreqZ + t));
254
192
  #endif
255
193
  #ifdef DETAIL_OCTAVE
256
- // A second, finer octave riding on the broad swell fine ripples on top of the big shape, a
257
- // shape vocabulary single-octave displacement can't reach. Shares the loop orbit so it stays
258
- // periodic when looping.
194
+ // A second, finer octave riding on the broad swell (loop-orbit shared so it stays periodic).
259
195
  #ifdef LOOP_MOTION
260
196
  pos.y += uDetailAmount * simplexNoise(vec2(pos.x * uDetailFreq, pos.z * uDetailFreq) + loopOff);
261
197
  #else
@@ -264,16 +200,8 @@ void main(){
264
200
  #endif
265
201
 
266
202
  #ifdef HELIX
267
- // Helix — the one shape the three twists below cannot reach. Their angle is freq * expStep(uv),
268
- // a MONOTONE falloff, so it can only ramp once; this one is periodic in uv.y (the length), so
269
- // uHelixTurns full turns land evenly from end to end. Runs AFTER the displacement so the noise
270
- // above still samples the undeformed pos.x/pos.z (byte-identical sampling), and BEFORE the twist
271
- // so the two compose.
272
- // roll rolls the ribbon's own cross-section about the axis in step with the sweep, swinging
273
- // its two long edges onto opposite sides — one wave becomes a ladder whose edges are
274
- // both strands (pair with the wireframe theme's rungs for the rungs between them).
275
- // radius carries the whole ribbon around the axis instead, orientation intact — a narrow ribbon
276
- // then reads as ONE strand, and a second wave at phase+180 is the other.
203
+ // Helix — the periodic sweep the three twists (monotone falloffs) can't reach. Runs AFTER the
204
+ // displacement (so the noise still samples undeformed pos) and BEFORE the twist (so they compose).
277
205
  float hAng = 6.28318530718 * uHelixTurns * uv.y + radians(uHelixPhase);
278
206
  // Roll about the ribbon's width centre, not the origin — see RIBBON_Z_CENTER in WaveGeometry.
279
207
  float rollA = hAng * uHelixRoll;
@@ -285,23 +213,19 @@ void main(){
285
213
  pos.z += uHelixRadius * sin(hAng);
286
214
  #endif
287
215
 
288
- // The X-twist frequency feeding rotB. Two modes: by default uTwFreqX is used
289
- // directly; the variant (used by the Wave 4 preset) modulates it with
290
- // simplex noise indexed along the ribbon (uv.y) so the twist breathes over time.
291
- // We gate the wobble with a #define so the compiled program is unchanged when off.
216
+ // The X-twist frequency feeding rotB; the TWIST_MOTION variant modulates it with simplex noise
217
+ // indexed along the ribbon (uv.y) so the twist breathes over time.
292
218
  float twistXFreq = uTwFreqX;
293
219
  #ifdef TWIST_MOTION
294
220
  #ifdef LOOP_MOTION
295
- float twistXNoise = simplexNoise(vec2(vUv.y * 2.0, 0.0) + loopOff);
221
+ float twistXNoise = simplexNoise(vec2(uv.y * 2.0, 0.0) + loopOff);
296
222
  #else
297
- float twistXNoise = simplexNoise(vec2(vUv.y * 2.0, t));
223
+ float twistXNoise = simplexNoise(vec2(uv.y * 2.0, t));
298
224
  #endif
299
225
  twistXFreq = uTwFreqX - twistXNoise * 0.1;
300
226
  #endif
301
227
 
302
- // Three-axis twist: expStep falloff sets how
303
- // sharply each rotation concentrates toward an edge. rotA keys off uv.x, rotB/rotC
304
- // off uv.y; axes (0.5,0,0.5) and (0,0.5,0.5) are normalised inside rotationMatrix.
228
+ // Three-axis twist (see the falloff-axis note: rotA keys off uv.x/WIDTH, rotB/rotC off uv.y/LENGTH).
305
229
  mat4 rotA = rotationMatrix(vec3(0.5, 0.0, 0.5), uTwFreqY * expStep(uv.x, uTwPowY));
306
230
  mat4 rotB = rotationMatrix(vec3(0.0, 0.5, 0.5), twistXFreq * expStep(uv.y, uTwPowX));
307
231
  mat4 rotC = rotationMatrix(vec3(0.5, 0.0, 0.5), uTwFreqZ * expStep(uv.y, uTwPowZ));
@@ -309,19 +233,66 @@ void main(){
309
233
  pos = (vec4(pos, 1.0) * rotB).xyz;
310
234
  pos = (vec4(pos, 1.0) * rotC).xyz;
311
235
 
312
- #ifdef POINTER_FX
313
- // Pointer field: displace along the wave's own (post-twist) up-axis, weighted by a screen-space
314
- // falloff around the smoothed cursor a circle at uShapeFlow 0, stretched along the ribbon as it
315
- // rises. Everything here is ADDITIVE and fenced, so the shared path above/below is untouched and
316
- // byte-identical when POINTER_FX is off.
317
- // Shared clip-space transform, computed once and reused for the cursor metric and the ribbon
318
- // tangent (the compiler is not guaranteed to CSE the triple product otherwise). Associativity is
319
- // unchanged, so preClip is bit-for-bit what the plain P*V*M*v product produced.
320
- mat4 mvp = projectionMatrix * viewMatrix * modelMatrix;
321
- vec4 preClip = mvp * vec4(pos, 1.0);
236
+ #ifdef RADIAL
237
+ // Radial fan: remap the ribbon to polar around the LOCAL origin so its LENGTH fans into a plume.
238
+ // uv.x (folded WIDTH) fan ANGLE across uRadialArc; uv.y (LENGTH) → RADIUS, so a constant-uv.x
239
+ // combed fiber becomes a constant-angle radial spoke. mix(pos, fanned, 0) is identity off is
240
+ // byte-identical. (Placement is the wave's position transform — the fan has no separate pivot.)
241
+ {
242
+ float rAng = radians(uRadialCenter) + (clamp(uv.x, 0.0, 1.0) - 0.5) * radians(uRadialArc);
243
+ float rRho = uRadialRadius + uv.y * 400.0 * uRadialSpread; // 400 = native ribbon length
244
+ vec3 rEr = vec3(cos(rAng), sin(rAng), 0.0); // radial dir, in local X–Y (screen plane)
245
+ vec3 rEt = vec3(-sin(rAng), cos(rAng), 0.0); // tangential
246
+ vec3 fanned = rEr * rRho
247
+ + rEt * (pos.z - ${(-8).toFixed(1)}) * 0.5
248
+ + vec3(0.0, 0.0, pos.y);
249
+ pos = mix(pos, fanned, clamp(uRadialAmount, 0.0, 1.0));
250
+ }
251
+ #endif
252
+
253
+ WaveShape s;
254
+ s.pos = pos;
255
+ s.rotA = rotA;
256
+ s.rotB = rotB;
257
+ s.rotC = rotC;
258
+ return s;
259
+ }
260
+ `;
261
+ const pointerFieldChunk = `
262
+ uniform vec2 uPointer; // smoothed pointer, NDC (-1..1)
263
+ uniform float uPointerActive; // presence ramp 0..1 × per-wave influence
264
+ uniform float uPointerRadius; // falloff radius in NDC-y units (config radius × 2)
265
+ uniform float uPointerAspect; // drawing-buffer dw/dh (circular screen falloff)
266
+ uniform float uPointerAgitate;
267
+ uniform float uPointerPush; // signed membrane dome at the cursor (+ repel / − attract)
268
+ uniform float uPointerWake; // drag-wake trough amplitude (behind the moving cursor)
269
+ uniform vec2 uPointerVel; // smoothed pointer velocity, NDC/s (drag-wake direction)
270
+ // Ribbon flow: stretch the falloff along the strip's length axis so the field reaches ALONG the
271
+ // ribbon rather than as a screen disc. 0 = the plain circular smoothstep (byte-identical when off).
272
+ uniform float uShapeFlow;
273
+ #ifdef POINTER_RIPPLES
274
+ uniform vec2 uRippleOrigin[4]; // NDC
275
+ uniform float uRippleAge[4]; // seconds since spawn (CPU-computed)
276
+ uniform float uRippleAmp[4]; // shared 0..1 decay envelope per slot (CPU-computed; 0 = slot free)
277
+ uniform float uPointerRipple; // THIS wave's ripple amplitude (scales the shared envelope)
278
+ const float RIPPLE_WAVE_SPEED = 0.85; // NDC/s the ring crest travels outward
279
+ const float RIPPLE_SIGMA = 0.14; // gaussian half-width of the travelling packet (NDC)
280
+ const float RIPPLE_FREQ = 11.0; // oscillation within the packet (one crest + faint troughs)
281
+ const float RIPPLE_MAX_R = 1.2; // reach where the crest has fully left the frame
282
+ #endif
283
+
284
+ // fall = screen falloff × presence (the wave's vPointerFall, which both fragment themes consume);
285
+ // disp = the signed displacement along the surface's own up-axis, which the CALLER applies (the wave
286
+ // in its local space, the dust through the wave's world matrix).
287
+ struct PointerHit { float fall; float disp; };
288
+
289
+ // Sample the field for ONE point. ndc is that point's screen position; mvp the clip transform of the
290
+ // space rotA/rotB/rotC and churnPos live in (the owning wave's local space); t / loopOff the caller's
291
+ // linear / orbit time — only the one selected by LOOP_MOTION is read.
292
+ PointerHit pointerField(vec2 ndc, mat4 mvp, mat4 rotA, mat4 rotB, mat4 rotC, vec3 churnPos,
293
+ float t, vec2 loopOff){
322
294
  // Screen-space offset from the cursor (aspect-corrected → round in pixels). The DEFAULT metric.
323
- vec2 ndcHere = preClip.xy / max(preClip.w, 1.0e-6);
324
- vec2 dp = (ndcHere - uPointer) * vec2(uPointerAspect, 1.0);
295
+ vec2 dp = (ndc - uPointer) * vec2(uPointerAspect, 1.0);
325
296
  // Ribbon flow: stretch the metric along the strip's own LENGTH axis so the field reaches ALONG the
326
297
  // ribbon and stays tight across it — the "flows with the material" feel, per-vertex (so it follows
327
298
  // the strip's curve) with no CPU surface pick. The length axis is local +X (uv.x runs with x)
@@ -339,23 +310,19 @@ void main(){
339
310
  dp = vec2(dot(dp, tang) / (1.0 + uShapeFlow * 2.5), dot(dp, nrm)); // up to 3.5× reach along length
340
311
  }
341
312
  }
342
- float fall = smoothstep(uPointerRadius, 0.0, length(dp));
343
- vPointerFall = fall * uPointerActive;
344
- // Displacement axis = local +Y carried through the SAME three twist rotations as pos (row-vector
345
- // convention). Rotations are linear, so post-twist axis displacement equals pre-twist Y displacement.
346
- vec3 dispAxis = (((vec4(0.0, 1.0, 0.0, 0.0) * rotA) * rotB) * rotC).xyz;
313
+ float fall = smoothstep(uPointerRadius, 0.0, length(dp)) * uPointerActive;
347
314
  // Agitation: a fast churn octave near the cursor (additive — never rewrites base noise t, which
348
315
  // would force restructuring the shared path). Loop-safe under both time variants.
349
316
  #ifdef LOOP_MOTION
350
- float disp = uPointerAgitate * vPointerFall
351
- * simplexNoise(vec2(pos.x * uDispFreqX * 3.0, pos.z * uDispFreqZ * 3.0) + loopOff * 4.0);
317
+ float disp = uPointerAgitate * fall
318
+ * simplexNoise(vec2(churnPos.x * uDispFreqX * 3.0, churnPos.z * uDispFreqZ * 3.0) + loopOff * 4.0);
352
319
  #else
353
- float disp = uPointerAgitate * vPointerFall
354
- * simplexNoise(vec2(pos.x * uDispFreqX * 3.0 + t * 4.0, pos.z * uDispFreqZ * 3.0));
320
+ float disp = uPointerAgitate * fall
321
+ * simplexNoise(vec2(churnPos.x * uDispFreqX * 3.0 + t * 4.0, churnPos.z * uDispFreqZ * 3.0));
355
322
  #endif
356
- // Membrane push/pull: a smooth dome (vPointerFall is the falloff) that swells toward you (+ repel)
357
- // or dents away (− attract) at the cursor, riding along with the sprung field.
358
- disp += uPointerPush * vPointerFall;
323
+ // Membrane push/pull: a smooth dome (fall is the falloff) that swells toward you (+ repel) or dents
324
+ // away (− attract) at the cursor, riding along with the sprung field.
325
+ disp += uPointerPush * fall;
359
326
  // Drag-wake: pull the surface just BEHIND the moving cursor into a trailing trough. dp points
360
327
  // from cursor to vertex; "behind" is how far the vertex sits opposite the velocity (0 ahead → 1 a
361
328
  // radius behind), gated by speed so it only forms while dragging and heals when the cursor stops.
@@ -363,12 +330,12 @@ void main(){
363
330
  float wakeSpeed = length(velC);
364
331
  if (uPointerWake != 0.0 && wakeSpeed > 1.0e-4) {
365
332
  float behind = clamp(dot(-dp, velC) / (wakeSpeed * uPointerRadius), 0.0, 1.0);
366
- disp -= uPointerWake * vPointerFall * behind * smoothstep(0.05, 0.6, wakeSpeed);
333
+ disp -= uPointerWake * fall * behind * smoothstep(0.05, 0.6, wakeSpeed);
367
334
  }
368
335
  #ifdef POINTER_RIPPLES
369
336
  for (int i = 0; i < 4; i++) {
370
337
  if (uRippleAmp[i] > 0.0) {
371
- float rd = length((preClip.xy / max(preClip.w, 1.0e-6) - uRippleOrigin[i]) * vec2(uPointerAspect, 1.0));
338
+ float rd = length((ndc - uRippleOrigin[i]) * vec2(uPointerAspect, 1.0));
372
339
  // A wave PACKET whose crest travels outward at RIPPLE_WAVE_SPEED: a gaussian window centred on
373
340
  // the moving front carrying a short oscillation (a raised ring with faint trailing troughs),
374
341
  // so the energy radiates instead of throbbing at the click point. The shared uRippleAmp
@@ -381,7 +348,99 @@ void main(){
381
348
  }
382
349
  }
383
350
  #endif
384
- pos += dispAxis * disp;
351
+ PointerHit hit;
352
+ hit.fall = fall;
353
+ hit.disp = disp;
354
+ return hit;
355
+ }
356
+ `;
357
+ const vertexShader = `
358
+ ${simplex2d}
359
+
360
+ uniform float uTime, uSpeed, uSeed;
361
+ uniform float uDispFreqX, uDispFreqZ, uDispAmount;
362
+ uniform float uDetailFreq, uDetailAmount; // 2nd displacement octave (only read under DETAIL_OCTAVE)
363
+ uniform float uTwFreqX, uTwFreqY, uTwFreqZ, uTwPowX, uTwPowY, uTwPowZ;
364
+ uniform float uLoopSeconds; // seamless-loop period (only read under LOOP_MOTION)
365
+
366
+ // Helix (optional). Behind HELIX so a wave without one compiles the exact same program — same
367
+ // byte-identity contract as the pointer block below.
368
+ #ifdef HELIX
369
+ uniform float uHelixTurns; // full turns from one end of the ribbon to the other
370
+ uniform float uHelixRadius; // orbit radius: carries the whole ribbon around the axis
371
+ uniform float uHelixRoll; // cross-section roll, as a fraction of the turns (1 = rigid ladder)
372
+ uniform float uHelixPhase; // degrees
373
+ #endif
374
+
375
+ // Radial fan (optional). Behind RADIAL so a wave without one compiles the exact same program (same
376
+ // byte-identity contract as HELIX / POINTER_FX above).
377
+ #ifdef RADIAL
378
+ uniform float uRadialAmount; // 0..1 blend (0 = identity)
379
+ uniform float uRadialArc; // fan spread, degrees
380
+ uniform float uRadialSpread; // length → radius scale
381
+ uniform float uRadialRadius; // source / inner radius
382
+ uniform float uRadialCenter; // base angle, degrees
383
+ #endif
384
+
385
+ varying vec2 vUv;
386
+ varying vec3 vWorldPos;
387
+ varying vec3 vViewDir;
388
+ varying vec4 vClipPosition; // = gl_Position, for the wireframe theme's depth fade
389
+
390
+ // Pointer field (optional, additive) — the shared chunk, gated so a wave with no interaction config
391
+ // compiles the exact same program. The particle emitter interpolates the SAME chunk, so dust reacts
392
+ // through one implementation of the footprint / falloff / displacement.
393
+ #ifdef POINTER_FX
394
+ ${pointerFieldChunk}
395
+ varying float vPointerFall; // falloff × presence — consumed by both fragment themes
396
+ #endif
397
+
398
+ ${waveShapeChunk}
399
+
400
+ void main(){
401
+ vUv = uv;
402
+ #ifndef LOOP_MOTION
403
+ float t = uTime * uSpeed + uSeed;
404
+ vec2 loopOff = vec2(0.0); // unused under linear time; kept so waveShape's signature is uniform
405
+ #endif
406
+
407
+ #ifdef LOOP_MOTION
408
+ // Seamless loop: rather than scrolling the noise field linearly by t (which never repeats),
409
+ // sample it on a circle of radius loopR at angle loopTheta — exactly periodic with period
410
+ // uLoopSeconds. The tangential speed loopR·dθ/dt equals uSpeed, so the looped motion advances
411
+ // at the same rate as the linear drift, just curved into a closed orbit (it orbits rather than
412
+ // drifts — the trade-off for a seamless loop, hence opt-in). uSeed offsets the phase so stacked
413
+ // waves keep their relative motion while sharing the single period.
414
+ float loopTheta = uTime * (6.28318530718 / uLoopSeconds) + uSeed;
415
+ float loopR = uSpeed * uLoopSeconds * 0.159154943092; // = uSpeed·uLoopSeconds / (2π)
416
+ vec2 loopOff = loopR * vec2(cos(loopTheta), sin(loopTheta));
417
+ float t = 0.0; // unused under loop time
418
+ #endif
419
+
420
+ // Deform the baked hairpin via the shared waveShape chunk (displacement + helix + twist + radial),
421
+ // which also drives the particle shed emitter. It returns the deformed local pos + the twist
422
+ // matrices the pointer field reads below.
423
+ WaveShape ws = waveShape(position, uv, t, loopOff);
424
+ vec3 pos = ws.pos;
425
+
426
+ #ifdef POINTER_FX
427
+ // Pointer field: displace along the wave's own (post-twist) up-axis, weighted by a screen-space
428
+ // falloff around the smoothed cursor — a circle at uShapeFlow 0, stretched along the ribbon as it
429
+ // rises. Everything here is ADDITIVE and fenced, so the shared path above/below is untouched and
430
+ // byte-identical when POINTER_FX is off. The field itself lives in pointerFieldChunk, which the
431
+ // particle emitter also calls — so dust reacts through this exact footprint and falloff.
432
+ // Shared clip-space transform, computed once and reused for the cursor metric and the ribbon
433
+ // tangent (the compiler is not guaranteed to CSE the triple product otherwise). Associativity is
434
+ // unchanged, so preClip is bit-for-bit what the plain P*V*M*v product produced.
435
+ mat4 mvp = projectionMatrix * viewMatrix * modelMatrix;
436
+ vec4 preClip = mvp * vec4(pos, 1.0);
437
+ PointerHit hit = pointerField(preClip.xy / max(preClip.w, 1.0e-6), mvp,
438
+ ws.rotA, ws.rotB, ws.rotC, pos, t, loopOff);
439
+ vPointerFall = hit.fall;
440
+ // Displacement axis = local +Y carried through the SAME three twist rotations as pos (row-vector
441
+ // convention). Rotations are linear, so post-twist axis displacement equals pre-twist Y displacement.
442
+ vec3 dispAxis = (((vec4(0.0, 1.0, 0.0, 0.0) * ws.rotA) * ws.rotB) * ws.rotC).xyz;
443
+ pos += dispAxis * hit.disp;
385
444
  #endif
386
445
 
387
446
  // The scale / rotation / position transform lives on the mesh (modelMatrix), so the
@@ -434,7 +493,7 @@ uniform vec3 uDepthTintColor;
434
493
  varying vec4 vClipPosition; // clip-space depth (written by the vertex shader for both programs)
435
494
  #endif
436
495
  #ifdef EDGE_FEATHER
437
- uniform float uEdgeFeather; // ribbon long-edge softness (only when it differs from the 0.1 default)
496
+ uniform float uEdgeFeather; // softness of the ribbon's two ENDS (only when it differs from 0.1)
438
497
  #endif
439
498
  #ifdef POINTER_FX
440
499
  uniform float uPointerThin; // 0..1 local translucency near the cursor
@@ -474,8 +533,10 @@ vec3 surfaceStreaks(vec2 uv, vec3 color, float crease){
474
533
  colorAtten = mix(colorAtten, prm.w, blend);
475
534
  paraPow = mix(paraPow, uNoiseBandParaPow[i], blend);
476
535
  }
477
- // The high frequency runs along uv.x (the ribbon's length) so the streaks read as
478
- // fine lengthwise fibers; end-weighted by 1 - parabola(uv.x).
536
+ // The high frequency runs along uv.x (the folded WIDTH see WaveGeometry's UV AXES note),
537
+ // packing many thin stripes across the cross-section while uv.y is barely scaled, so each
538
+ // one stretches out into a fine LENGTHWISE fiber. 1 - parabola(uv.x) then weights them
539
+ // toward the two long edges and away from the width centreline.
479
540
  float p = 1.0 - parabola(uv.x, paraPow);
480
541
  float n0 = simplexNoise(vec2(uv.x * 0.1, uv.y * 0.5));
481
542
  float n1 = simplexNoise(vec2(uv.x * (freq + freq * 0.5 * n0), uv.y * 4.0 * n0));
@@ -564,7 +625,8 @@ void main(){
564
625
 
565
626
  if (uTexture > 0.001) col *= 1.0 + (grainHash(vUv * 850.0) - 0.5) * uTexture * 0.25;
566
627
 
567
- // Soft long edges + optional viewport-edge fade. The edge softness is the hardcoded 0.1 by
628
+ // Soft ribbon ENDS (it fades on vUv.y, the length) + optional viewport-edge fade. The edge
629
+ // softness is the hardcoded 0.1 by
568
630
  // default (literal branch → byte-identical); EDGE_FEATHER swaps in the uEdgeFeather knob only
569
631
  // when it differs, so razor-crisp or vapor-soft edges are both reachable.
570
632
  #ifdef EDGE_FEATHER
@@ -638,7 +700,7 @@ void main(){
638
700
  color *= 1.0 + uPointerLighten * vPointerFall;
639
701
  #endif
640
702
 
641
- // Carve into fine vertical lines; thickness from the screen-space uv derivative.
703
+ // Carve into fine lengthwise strands; thickness from the screen-space uv derivative.
642
704
  vec2 dy = dFdy(vUv);
643
705
  float lineThickness = uLineThickness * pow(abs(dy.x * uMaxWidth), uLineDerivativePower);
644
706
  #ifdef POINTER_FX
@@ -897,7 +959,195 @@ void main(){
897
959
  gl_FragColor = vec4(mix(src.rgb, outc, clamp(uHalftoneCmyk, 0.0, 1.0)), src.a);
898
960
  }
899
961
  `;
962
+ const particleVertexShader = `
963
+ attribute float aSeed;
964
+ attribute vec4 aRnd;
965
+ attribute vec2 aUv; // where this particle spawns on the ribbon (x = flank, y = along length; edge-biased at build)
966
+
967
+ uniform float uTime, uLoopSeconds, uLife, uSize, uSizeJitter, uTwinkle, uPixelRatio;
968
+ uniform float uPartSpeed;
969
+ uniform vec3 uColor, uColor2, uCenter, uRight, uUp;
970
+ uniform float uDrift, uRise, uSwirl, uWander;
971
+
972
+ // The owning wave's shape, mirrored in configure() so the dust rides the SAME deform as the ribbon.
973
+ // The HELIX/RADIAL uniform blocks are declared only when the matching #define is set.
974
+ ${simplex2d}
975
+ uniform float uDispFreqX, uDispFreqZ, uDispAmount;
976
+ uniform float uDetailFreq, uDetailAmount;
977
+ uniform float uTwFreqX, uTwFreqY, uTwFreqZ, uTwPowX, uTwPowY, uTwPowZ;
978
+ #ifdef HELIX
979
+ uniform float uHelixTurns, uHelixRadius, uHelixRoll, uHelixPhase;
980
+ #endif
981
+ #ifdef RADIAL
982
+ uniform float uRadialAmount, uRadialArc, uRadialSpread, uRadialRadius, uRadialCenter;
983
+ #endif
984
+ uniform mat4 uShedModel; // the wave's matrixWorld (deformed LOCAL → world)
985
+ uniform float uShedSpeed, uShedSeed;
986
+ ${waveShapeChunk}
987
+
988
+ // The cursor. Same chunk the ribbon uses, mirrored onto this material in ParticleField.configure(),
989
+ // and behind the same POINTER_FX gate — a wave with no hover field compiles the point program it
990
+ // always did. uPartShove is the one particle-only knob (see the two samples in main).
991
+ #ifdef POINTER_FX
992
+ ${pointerFieldChunk}
993
+ uniform float uPartShove; // how hard the cursor shoves dust that has already drifted free (0 = off)
994
+ #endif
995
+
996
+ varying float vAlpha;
997
+ varying vec3 vColor;
998
+ varying vec2 vDir; // screen-space motion direction (for the streak sprite)
999
+
1000
+ const float TAU = 6.28318530718;
1001
+
1002
+ void main(){
1003
+ // Deterministic life: age 0..1 from uTime + a per-particle seed. Advances once per loop period when
1004
+ // looping (so the whole field repeats seamlessly), else once per uLife seconds. uPartSpeed scales the
1005
+ // cadence (motion speed); under a loop it snaps to a whole number of cycles so the seam stays seamless.
1006
+ float cyc = max(1.0, floor(uPartSpeed + 0.5));
1007
+ float rate = (uLoopSeconds > 0.0) ? (uTime / uLoopSeconds * cyc) : (uTime * uPartSpeed / max(uLife, 0.001));
1008
+ float age = fract(rate + aSeed);
1009
+ float fade = sin(3.14159265 * age); // 0 at birth/death, 1 mid-life
1010
+
1011
+ // Spawn on the owning wave's DEFORMED surface / edge at aUv (via the shared waveShape), then peel
1012
+ // outward from the wave centre as the particle ages — silk dissolving into glitter.
1013
+ float ts = uTime * uShedSpeed + uShedSeed;
1014
+ vec2 loopOff = vec2(0.0);
1015
+ #ifdef LOOP_MOTION
1016
+ float loopTheta = uTime * (TAU / uLoopSeconds) + uShedSeed;
1017
+ float loopR = uShedSpeed * uLoopSeconds * 0.159154943092;
1018
+ loopOff = loopR * vec2(cos(loopTheta), sin(loopTheta));
1019
+ ts = 0.0;
1020
+ #endif
1021
+ // Approximate the base hairpin point for this uv (length from uv.y; width centre), then deform it
1022
+ // exactly as the wave does. Good enough for dust — the fan / displacement dominate.
1023
+ vec3 base = vec3((aUv.y - 0.5) * 400.0, 0.0, ${(-8).toFixed(1)});
1024
+ WaveShape ws = waveShape(base, aUv, ts, loopOff);
1025
+ vec3 origin = (uShedModel * vec4(ws.pos, 1.0)).xyz;
1026
+ vec3 outward = normalize(origin - uCenter + vec3(1e-4));
1027
+
1028
+ #ifdef POINTER_FX
1029
+ // WELD (applied below, once the mote's own motion is known). The ribbon displaces its surface by
1030
+ // pointerField() along its own post-twist up-axis; a mote sitting ON that surface has to take the
1031
+ // same ride, or the cursor's dome lifts the silk out from under its own glitter. Sampled at the
1032
+ // SPAWN point and carried through the local→world matrix exactly as the ribbon's own
1033
+ // pos += dispAxis * disp is, so the two land on the same place. Sampling at the spawn point also
1034
+ // leaves outward derived from the UNDISPLACED origin, so a poke never bends the drift direction.
1035
+ mat4 pMvp = projectionMatrix * viewMatrix * uShedModel;
1036
+ vec4 originClip = pMvp * vec4(ws.pos, 1.0);
1037
+ vec3 dispAxis = mat3(uShedModel) * (((vec4(0.0, 1.0, 0.0, 0.0) * ws.rotA) * ws.rotB) * ws.rotC).xyz;
1038
+ PointerHit weld = pointerField(originClip.xy / max(originClip.w, 1.0e-6), pMvp,
1039
+ ws.rotA, ws.rotB, ws.rotC, ws.pos, ts, loopOff);
1040
+ #endif
1041
+
1042
+ vec3 p = origin + outward * age * uDrift + (aRnd.xyz - 0.5) * age * uDrift * 0.35;
1043
+
1044
+ // Motion styles, each 0 = off, all riding age so they stay loop-safe (the age wrap is hidden by
1045
+ // fade→0 at birth/death). rise = screen-vertical buoyancy (embers up / snow down); swirl = orbit
1046
+ // around the wave centre in the screen plane; wander = curl-noise turbulence (fireflies / motes).
1047
+ p += uUp * age * uRise;
1048
+ // How far this mote travels away from its birth patch over a WHOLE life — the straight-line terms
1049
+ // plus, under swirl, the arc it sweeps at its own orbit radius. Only the pointer weld reads it
1050
+ // (0 for dust that merely clings to the surface, which is exactly the case age would get wrong),
1051
+ // so it is fenced like everything else the cursor drives.
1052
+ #ifdef POINTER_FX
1053
+ float span = abs(uDrift) * 1.35 + abs(uRise) + uWander;
1054
+ #endif
1055
+ if (uSwirl != 0.0) {
1056
+ vec3 nrm = cross(uRight, uUp);
1057
+ vec3 rel = p - uCenter;
1058
+ #ifdef POINTER_FX
1059
+ span += abs(uSwirl) * TAU * length(rel);
1060
+ #endif
1061
+ float rx = dot(rel, uRight), ry = dot(rel, uUp), rz = dot(rel, nrm);
1062
+ float a = age * uSwirl * TAU;
1063
+ float ca = cos(a), sa = sin(a);
1064
+ p = uCenter + uRight * (rx * ca - ry * sa) + uUp * (rx * sa + ry * ca) + nrm * rz;
1065
+ }
1066
+ if (uWander != 0.0) {
1067
+ vec2 wan = vec2(simplexNoise(vec2(aSeed * 17.0, age * 3.0)),
1068
+ simplexNoise(vec2(age * 3.0, aSeed * 23.0)));
1069
+ p += (uRight * wan.x + uUp * wan.y) * uWander;
1070
+ }
1071
+
1072
+ #ifdef POINTER_FX
1073
+ // How attached to its birth patch this mote still is: 1 while it sits on the surface, 0 once it
1074
+ // has travelled a full life's worth away. Measured from the DISTANCE it actually moved rather than
1075
+ // from age, because dust with no drift / rise / swirl / wander never leaves the surface at all —
1076
+ // an age fade would quietly stop that dust from following the ribbon halfway through its life.
1077
+ float attach = span > 1.0e-4 ? 1.0 - clamp(length(p - origin) / span, 0.0, 1.0) : 1.0;
1078
+ p += dispAxis * (weld.disp * attach);
1079
+ // SHOVE: the exact complement. The same field sampled at the mote's OWN screen position, so the
1080
+ // cursor also pushes dust that has already left the surface — and a click ripple visibly blows
1081
+ // through the cloud instead of stopping dead at the ribbon. Uniform branch (warp-coherent), so
1082
+ // uPartShove 0 costs nothing.
1083
+ if (uPartShove != 0.0) {
1084
+ vec4 pClip = projectionMatrix * viewMatrix * vec4(p, 1.0);
1085
+ PointerHit shove = pointerField(pClip.xy / max(pClip.w, 1.0e-6), pMvp,
1086
+ ws.rotA, ws.rotB, ws.rotC, ws.pos, ts, loopOff);
1087
+ p += dispAxis * (shove.disp * (1.0 - attach) * uPartShove);
1088
+ }
1089
+ #endif
1090
+
1091
+ float tw = 0.5 + 0.5 * sin((age * 9.0 + aSeed) * TAU); // loop-safe flicker (rides age)
1092
+ vAlpha = fade * mix(1.0, tw, clamp(uTwinkle, 0.0, 1.0));
1093
+ vColor = mix(uColor, uColor2, aRnd.w); // two-tone dust: per-particle blend of the two colours
1094
+ vDir = normalize(vec2(dot(outward, uRight), dot(outward, uUp)) + vec2(1e-4)); // outward, in screen space
1095
+ gl_Position = projectionMatrix * viewMatrix * vec4(p, 1.0);
1096
+ // Orthographic camera → point size is constant in device pixels (no perspective depth divide).
1097
+ float jitter = 1.0 + uSizeJitter * (aSeed - 0.5) * 2.0;
1098
+ gl_PointSize = max(uSize * uPixelRatio * jitter * fade, 0.0);
1099
+ }
1100
+ `;
1101
+ const particleFragmentShader = `
1102
+ precision highp float;
1103
+ uniform float uShape; // 0 glitter · 1 soft · 2 ring · 3 star · 4 streak
1104
+ varying float vAlpha;
1105
+ varying vec3 vColor;
1106
+ varying vec2 vDir;
1107
+ // User artwork (shape "sprite"), behind a define so a field without one compiles the exact same
1108
+ // program — and so the sampler only exists once a texture is actually bound to it. ONE texture is
1109
+ // shared by every particle in the field; see ParticleField.loadSprite for the rasterization.
1110
+ #ifdef PARTICLE_SPRITE
1111
+ uniform sampler2D uSprite;
1112
+ #endif
1113
+ void main(){
1114
+ #ifdef PARTICLE_SPRITE
1115
+ // gl_PointCoord's origin is the sprite's TOP-left with y running DOWN, so it has to be flipped or
1116
+ // every sprite draws upside down. The procedural shapes below never needed this — they are all
1117
+ // symmetric about y, which is exactly why the bug would have gone unnoticed.
1118
+ vec4 tex = texture2D(uSprite, vec2(gl_PointCoord.x, 1.0 - gl_PointCoord.y));
1119
+ float a = tex.a * vAlpha;
1120
+ if (a <= 0.0) discard;
1121
+ // Tinted by the dust colour so color / color2 keep working: white artwork takes the tint
1122
+ // exactly, coloured artwork multiplies it.
1123
+ gl_FragColor = vec4(vColor * tex.rgb, a);
1124
+ #else
1125
+ vec2 pc = gl_PointCoord - 0.5;
1126
+ float d = length(pc);
1127
+ int s = int(uShape + 0.5);
1128
+ float a;
1129
+ if (s == 1) { // soft: a diffuse gaussian blob (motes / pollen)
1130
+ a = exp(-d * d * 7.0);
1131
+ } else if (s == 2) { // ring: a hollow band (bubbles)
1132
+ a = smoothstep(0.09, 0.0, abs(d - 0.34));
1133
+ } else if (s == 3) { // star: a 4-point sparkle
1134
+ float ang = atan(pc.y, pc.x);
1135
+ float spike = pow(abs(cos(ang * 2.0)), 6.0);
1136
+ a = smoothstep(1.0, 0.0, d / (0.14 + 0.5 * spike));
1137
+ } else if (s == 4) { // streak: an elongated comet along the motion direction
1138
+ float along = dot(pc, vDir);
1139
+ float perp = dot(pc, vec2(-vDir.y, vDir.x));
1140
+ a = smoothstep(0.5, 0.0, length(vec2(along * 0.42, perp * 2.2)));
1141
+ } else { // glitter (0): the soft round additive disc
1142
+ a = smoothstep(0.5, 0.0, d);
1143
+ }
1144
+ a *= vAlpha;
1145
+ if (a <= 0.0) discard;
1146
+ gl_FragColor = vec4(vColor, a); // AdditiveBlending (src = SrcAlpha) → adds vColor·a
1147
+ #endif
1148
+ }
1149
+ `;
900
1150
  //#endregion
901
- export { ditherFragmentShader, fragmentShader, halftoneCmykFragmentShader, halftoneFragmentShader, heatmapFragmentShader, innerLightFragmentShader, lineFragmentShader, paperTextureFragmentShader, postFragmentShader, postVertexShader, vertexShader };
1151
+ export { ditherFragmentShader, fragmentShader, halftoneCmykFragmentShader, halftoneFragmentShader, heatmapFragmentShader, innerLightFragmentShader, lineFragmentShader, paperTextureFragmentShader, particleFragmentShader, particleVertexShader, postFragmentShader, postVertexShader, vertexShader };
902
1152
 
903
1153
  //# sourceMappingURL=shaders.js.map