@wave3d/core 0.10.0 → 0.11.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.
Files changed (53) hide show
  1. package/dist/config/model.d.ts +209 -6
  2. package/dist/config/model.js +85 -3
  3. package/dist/config/model.js.map +1 -1
  4. package/dist/index.d.ts +3 -2
  5. package/dist/index.js +3 -2
  6. package/dist/presets.js +295 -0
  7. package/dist/presets.js.map +1 -1
  8. package/dist/renderer/WaveGeometry.js +21 -0
  9. package/dist/renderer/WaveGeometry.js.map +1 -1
  10. package/dist/renderer/WaveRenderer.d.ts +62 -0
  11. package/dist/renderer/WaveRenderer.js +349 -10
  12. package/dist/renderer/WaveRenderer.js.map +1 -1
  13. package/dist/renderer/WaveRendererGPU.js +32 -2
  14. package/dist/renderer/WaveRendererGPU.js.map +1 -1
  15. package/dist/renderer/interaction.js +12 -0
  16. package/dist/renderer/interaction.js.map +1 -1
  17. package/dist/renderer/particleField.js +26 -2
  18. package/dist/renderer/particleField.js.map +1 -1
  19. package/dist/renderer/particleFieldGPU.js +6 -0
  20. package/dist/renderer/particleFieldGPU.js.map +1 -1
  21. package/dist/renderer/shaders.js +748 -13
  22. package/dist/renderer/shaders.js.map +1 -1
  23. package/dist/renderer/tsl/dissolve.js +56 -0
  24. package/dist/renderer/tsl/dissolve.js.map +1 -0
  25. package/dist/renderer/tsl/particleMaterial.js +46 -8
  26. package/dist/renderer/tsl/particleMaterial.js.map +1 -1
  27. package/dist/renderer/tsl/uniforms.js +40 -1
  28. package/dist/renderer/tsl/uniforms.js.map +1 -1
  29. package/dist/renderer/tsl/waveMaterial.js +275 -26
  30. package/dist/renderer/tsl/waveMaterial.js.map +1 -1
  31. package/dist/renderer/tsl/waveShape.js +31 -10
  32. package/dist/renderer/tsl/waveShape.js.map +1 -1
  33. package/dist/renderer/wavePath.js +189 -0
  34. package/dist/renderer/wavePath.js.map +1 -0
  35. package/dist/shell/createWave.d.ts +23 -3
  36. package/dist/shell/createWave.js +5 -4
  37. package/dist/shell/createWave.js.map +1 -1
  38. package/dist/shell/probe.d.ts +28 -0
  39. package/dist/shell/probe.js +58 -11
  40. package/dist/shell/probe.js.map +1 -1
  41. package/dist/standalone/wave3d.standalone.js +3401 -2108
  42. package/dist/standalone/wave3d.standalone.webgpu.js +7372 -5848
  43. package/dist/standalone.d.ts +2 -2
  44. package/dist/standalone.js +2 -2
  45. package/dist/studio/StudioWaveRenderer.d.ts +115 -8
  46. package/dist/studio/StudioWaveRenderer.js +588 -14
  47. package/dist/studio/StudioWaveRenderer.js.map +1 -1
  48. package/dist/studio/index.d.ts +2 -2
  49. package/dist/studio/index.js.map +1 -1
  50. package/dist/studio/randomize.js +0 -1
  51. package/dist/studio/randomize.js.map +1 -1
  52. package/package.json +1 -1
  53. package/skills/wave3d/SKILL.md +142 -5
@@ -199,6 +199,7 @@ WaveShape waveShape(vec3 position, vec2 uv, float t, vec2 loopOff){
199
199
  #endif
200
200
  #endif
201
201
 
202
+
202
203
  #ifdef HELIX
203
204
  // Helix — the periodic sweep the three twists (monotone falloffs) can't reach. Runs AFTER the
204
205
  // displacement (so the noise still samples undeformed pos) and BEFORE the twist (so they compose).
@@ -233,23 +234,77 @@ WaveShape waveShape(vec3 position, vec2 uv, float t, vec2 loopOff){
233
234
  pos = (vec4(pos, 1.0) * rotB).xyz;
234
235
  pos = (vec4(pos, 1.0) * rotC).xyz;
235
236
 
237
+
236
238
  #ifdef RADIAL
237
239
  // Radial fan: remap the ribbon to polar around the LOCAL origin so its LENGTH fans into a plume.
238
240
  // uv.x (folded WIDTH) → fan ANGLE across uRadialArc; uv.y (LENGTH) → RADIUS, so a constant-uv.x
239
241
  // combed fiber becomes a constant-angle radial spoke. mix(pos, fanned, 0) is identity → off is
240
242
  // byte-identical. (Placement is the wave's position transform — the fan has no separate pivot.)
241
243
  {
242
- float rAng = radians(uRadialCenter) + (clamp(uv.x, 0.0, 1.0) - 0.5) * radians(uRadialArc);
244
+ // Swirl: let the ANGLE advance along the band as well as across it, so the arm curves around the
245
+ // throat into a spiral instead of running straight out from it. Radius already grows with uv.y,
246
+ // so angle gaining with uv.y too is exactly what makes a spiral — and it is the one thing a fan
247
+ // cannot do otherwise, since its angle comes from uv.x alone.
248
+ float rAng = radians(uRadialCenter) + (clamp(uv.x, 0.0, 1.0) - 0.5) * radians(uRadialArc)
249
+ + uv.y * radians(uRadialSwirl);
243
250
  float rRho = uRadialRadius + uv.y * 400.0 * uRadialSpread; // 400 = native ribbon length
244
251
  vec3 rEr = vec3(cos(rAng), sin(rAng), 0.0); // radial dir, in local X–Y (screen plane)
245
252
  vec3 rEt = vec3(-sin(rAng), cos(rAng), 0.0); // tangential
246
253
  vec3 fanned = rEr * rRho
247
254
  + rEt * (pos.z - ${(-8).toFixed(1)}) * 0.5
248
- + vec3(0.0, 0.0, pos.y);
255
+ // Cone: lift the fan out of its own plane as it spreads, so the flat plume becomes a
256
+ // TRUMPET whose combed strands run down the slant into the throat. 0 is the flat fan.
257
+ + vec3(0.0, 0.0, pos.y + uv.y * 400.0 * uRadialCone);
249
258
  pos = mix(pos, fanned, clamp(uRadialAmount, 0.0, 1.0));
250
259
  }
251
260
  #endif
252
261
 
262
+ #ifdef PATH
263
+ // PATH — sweep the ribbon along an authored centreline. Everything above deformed a ribbon whose
264
+ // centreline was the x-axis on the plane z = RIBBON_Z_CENTER; this carries that deformed
265
+ // cross-section onto a curve instead. It is the LAST stage, so it bends whatever the ribbon has
266
+ // already become — twists, helix, radial fan and all.
267
+ //
268
+ // A STRAIGHT path along the ribbon's own centreline is exactly the identity, which is what lets
269
+ // the studio give a wave a path the moment it is double-clicked without the wave moving. Four
270
+ // details hold that up, and each of them once broke it: the frame is right-handed (binormal +Z on
271
+ // a straight path — a mirrored one flips the handedness of every twist); the LUT is read at
272
+ // texel CENTRES and interpolated in-shader (reading it at s stretched the ribbon ~0.8%
273
+ // about its middle); an open path is
274
+ // EXTRAPOLATED past its ends along the end tangent rather than clamped (twists push vertices past
275
+ // ±200, and clamping collapsed them onto the last frame); and a closed one wraps.
276
+ //
277
+ // Position, frame and width come from a small LUT the CPU baked (see wavePath.ts) — the frame is
278
+ // parallel-transported, which cannot be done per vertex — and the two frame vectors are
279
+ // re-normalized because a linear interpolation between unit vectors is not one.
280
+ {
281
+ float pathSRaw = (pos.x + 200.0) / 400.0;
282
+ // Closure is in the binormal row's alpha; it decides how s is addressed, so read it first.
283
+ // texture2D, not texture2DLod: three rewrites GLSL1 to GLSL3 on WebGL2 by replacing the
284
+ // plain texture2D token, and the Lod spelling survives that rewrite as an undefined function.
285
+ bool pathClosed = texture2D(uPathTex, vec2(${(.5 / 128).toFixed(8)}, ${(2.5 / 3).toFixed(7)})).w > 0.5;
286
+ float pathS = pathClosed ? fract(pathSRaw) : clamp(pathSRaw, 0.0, 1.0);
287
+ // Interpolate between the two neighbouring samples HERE, at full precision, reading each at its
288
+ // texel centre from a NEAREST texture. Hardware filtering of float32 is neither guaranteed (it
289
+ // needs an extension) nor exact (its weights may be quantized) — see bakePathTexture.
290
+ float pathI = pathS * ${127 .toFixed(1)};
291
+ float pathI0 = floor(pathI);
292
+ float pathF = pathI - pathI0;
293
+ float pathU0 = (pathI0 + 0.5) / ${128 .toFixed(1)};
294
+ float pathU1 = (min(pathI0 + 1.0, ${127 .toFixed(1)}) + 0.5) / ${128 .toFixed(1)};
295
+ vec4 pP = mix(texture2D(uPathTex, vec2(pathU0, ${(.5 / 3).toFixed(7)})),
296
+ texture2D(uPathTex, vec2(pathU1, ${(.5 / 3).toFixed(7)})), pathF);
297
+ vec4 pNL = mix(texture2D(uPathTex, vec2(pathU0, ${(1.5 / 3).toFixed(7)})),
298
+ texture2D(uPathTex, vec2(pathU1, ${(1.5 / 3).toFixed(7)})), pathF); // .w = arc length
299
+ vec3 pN = normalize(pNL.xyz);
300
+ vec3 pB = normalize(mix(texture2D(uPathTex, vec2(pathU0, ${(2.5 / 3).toFixed(7)})),
301
+ texture2D(uPathTex, vec2(pathU1, ${(2.5 / 3).toFixed(7)})), pathF).xyz);
302
+ float pathPast = pathClosed ? 0.0 : (pathSRaw - pathS) * pNL.w;
303
+ pos = pP.xyz + cross(pN, pB) * pathPast + pN * pos.y
304
+ + pB * ((pos.z - ${(-8).toFixed(1)}) * pP.w);
305
+ }
306
+ #endif
307
+
253
308
  WaveShape s;
254
309
  s.pos = pos;
255
310
  s.rotA = rotA;
@@ -258,6 +313,57 @@ WaveShape waveShape(vec3 position, vec2 uv, float t, vec2 loopOff){
258
313
  return s;
259
314
  }
260
315
  `;
316
+ const dissolveChunk = `
317
+ uniform float uDissolveAmount; // 0..1 — how far the front has swept
318
+ uniform float uDissolveBand; // width of the crumbling band, in uv
319
+ uniform float uDissolveScale; // chunks across the ribbon's width
320
+ uniform float uDissolveBlocky; // 0 = organic noise blobs, 1 = hard quantized cells
321
+ uniform float uDissolveAxis; // 0 length (uv.y) · 1 width (uv.x) · 2 screen X · 3 screen Y
322
+ uniform float uDissolveReverse; // 1 = sweep from the far end instead
323
+
324
+ // The sweep coordinate, 0 where the front starts and 1 where it ends. Two families:
325
+ // - the RIBBON's own axes (uv), so the front follows the sheet wherever the twist takes it;
326
+ // - SCREEN space (ndc, 0..1 across the frame), so the front is a straight line on the canvas and
327
+ // every wave in a stack disintegrates against the SAME edge no matter how each one is oriented.
328
+ // The crumb pattern always stays in uv, so the chunks belong to the surface either way.
329
+ float dissolveCoord(vec2 uv, vec2 ndc){
330
+ float c = uDissolveAxis < 0.5 ? uv.y
331
+ : uDissolveAxis < 1.5 ? uv.x
332
+ : uDissolveAxis < 2.5 ? ndc.x
333
+ : ndc.y;
334
+ return uDissolveReverse > 0.5 ? 1.0 - c : c;
335
+ }
336
+
337
+ // How far the front has passed a point: 0 ahead of it (intact), 1 fully behind it (gone).
338
+ float dissolveProgress(float coord){
339
+ float band = max(uDissolveBand, 1.0e-3);
340
+ float front = uDissolveAmount * (1.0 + band); // 0 -> band sits entirely before the ribbon
341
+ return clamp((front - coord) / band, 0.0, 1.0);
342
+ }
343
+
344
+ // Per-chunk hash, cheap and stable: the same cell always returns the same value, so a chunk that
345
+ // has crumbled stays crumbled as the front advances (it never flickers back).
346
+ float dissolveHash(vec2 cell){
347
+ return fract(sin(dot(floor(cell), vec2(127.1, 311.7))) * 43758.5453);
348
+ }
349
+
350
+ // The erosion grain at a uv: 0 = the first thing to go, 1 = the last. Two octaves (coarse chunks
351
+ // with finer grit inside them) blended between smooth simplex (organic tatters) and quantized
352
+ // cells (hard blocky debris) by uDissolveBlocky. Cells are made square ON THE RIBBON — the sheet
353
+ // is 400 long by ~188 wide, so uv.y is stretched by that ratio.
354
+ float dissolveGrain(vec2 uv){
355
+ vec2 cell = vec2(uv.x, uv.y * 2.13) * uDissolveScale;
356
+ float coarse = mix(simplexNoise(cell) * 0.5 + 0.5, dissolveHash(cell), uDissolveBlocky);
357
+ float fine = mix(simplexNoise(cell * 3.7) * 0.5 + 0.5, dissolveHash(cell * 3.7), uDissolveBlocky);
358
+ return clamp(coarse * 0.72 + fine * 0.28, 0.0, 1.0);
359
+ }
360
+
361
+ // True where the surface has been eaten away. ndc is this fragment's 0..1 screen position (from
362
+ // vClipPosition), read only by the screen-space axes.
363
+ bool dissolved(vec2 uv, vec2 ndc){
364
+ return dissolveProgress(dissolveCoord(uv, ndc)) > dissolveGrain(uv);
365
+ }
366
+ `;
261
367
  const pointerFieldChunk = `
262
368
  uniform vec2 uPointer; // smoothed pointer, NDC (-1..1)
263
369
  uniform float uPointerActive; // presence ramp 0..1 × per-wave influence
@@ -372,6 +478,11 @@ uniform float uHelixRoll; // cross-section roll, as a fraction of the turns (1
372
478
  uniform float uHelixPhase; // degrees
373
479
  #endif
374
480
 
481
+ // Path (optional): the baked centreline LUT — row 0 position + width, row 1 normal, row 2 binormal.
482
+ #ifdef PATH
483
+ uniform sampler2D uPathTex;
484
+ #endif
485
+
375
486
  // Radial fan (optional). Behind RADIAL so a wave without one compiles the exact same program (same
376
487
  // byte-identity contract as HELIX / POINTER_FX above).
377
488
  #ifdef RADIAL
@@ -380,6 +491,8 @@ uniform float uRadialArc; // fan spread, degrees
380
491
  uniform float uRadialSpread; // length → radius scale
381
492
  uniform float uRadialRadius; // source / inner radius
382
493
  uniform float uRadialCenter; // base angle, degrees
494
+ uniform float uRadialCone; // lift per unit radius: 0 = a flat fan, >0 = a cone / trumpet
495
+ uniform float uRadialSwirl; // degrees of angle gained over the band's length: 0 = straight arms
383
496
  #endif
384
497
 
385
498
  varying vec2 vUv;
@@ -387,6 +500,16 @@ varying vec3 vWorldPos;
387
500
  varying vec3 vViewDir;
388
501
  varying vec4 vClipPosition; // = gl_Position, for the wireframe theme's depth fade
389
502
 
503
+ // Vertex normal (optional): the deformed surface's normal from finite differences of the SAME
504
+ // deformation at two baked neighbours — exact for whatever the shape does, and smooth across the
505
+ // mesh where the fragment's dFdx normal is constant per triangle. Glass reads it; behind
506
+ // VERTEX_NORMAL so the other themes compile the exact same program.
507
+ #ifdef VERTEX_NORMAL
508
+ attribute vec4 positionU; // next vertex across the width: xyz = base position, w = signed uv.x step
509
+ attribute vec4 positionV; // next vertex along the length, likewise (w = signed uv.y step)
510
+ varying vec3 vNormal; // world space, unit length; zero where the surface is degenerate
511
+ #endif
512
+
390
513
  // Pointer field (optional, additive) — the shared chunk, gated so a wave with no interaction config
391
514
  // compiles the exact same program. The particle emitter interpolates the SAME chunk, so dust reacts
392
515
  // through one implementation of the footprint / falloff / displacement.
@@ -422,6 +545,13 @@ void main(){
422
545
  // matrices the pointer field reads below.
423
546
  WaveShape ws = waveShape(position, uv, t, loopOff);
424
547
  vec3 pos = ws.pos;
548
+ #ifdef VERTEX_NORMAL
549
+ // The two neighbours through the SAME deformation (and the same pointer bump, below).
550
+ WaveShape wsU = waveShape(positionU.xyz, uv + vec2(positionU.w, 0.0), t, loopOff);
551
+ WaveShape wsV = waveShape(positionV.xyz, uv + vec2(0.0, positionV.w), t, loopOff);
552
+ vec3 posU = wsU.pos;
553
+ vec3 posV = wsV.pos;
554
+ #endif
425
555
 
426
556
  #ifdef POINTER_FX
427
557
  // Pointer field: displace along the wave's own (post-twist) up-axis, weighted by a screen-space
@@ -441,6 +571,29 @@ void main(){
441
571
  // convention). Rotations are linear, so post-twist axis displacement equals pre-twist Y displacement.
442
572
  vec3 dispAxis = (((vec4(0.0, 1.0, 0.0, 0.0) * ws.rotA) * ws.rotB) * ws.rotC).xyz;
443
573
  pos += dispAxis * hit.disp;
574
+ #ifdef VERTEX_NORMAL
575
+ // The neighbours ride the same bump, each from its own clip position and its own twist frame, so
576
+ // the normal follows the pointer's displacement rather than ignoring it.
577
+ vec4 clipU = mvp * vec4(posU, 1.0);
578
+ PointerHit hitU = pointerField(clipU.xy / max(clipU.w, 1.0e-6), mvp,
579
+ wsU.rotA, wsU.rotB, wsU.rotC, posU, t, loopOff);
580
+ posU += (((vec4(0.0, 1.0, 0.0, 0.0) * wsU.rotA) * wsU.rotB) * wsU.rotC).xyz * hitU.disp;
581
+ vec4 clipV = mvp * vec4(posV, 1.0);
582
+ PointerHit hitV = pointerField(clipV.xy / max(clipV.w, 1.0e-6), mvp,
583
+ wsV.rotA, wsV.rotB, wsV.rotC, posV, t, loopOff);
584
+ posV += (((vec4(0.0, 1.0, 0.0, 0.0) * wsV.rotA) * wsV.rotB) * wsV.rotC).xyz * hitV.disp;
585
+ #endif
586
+ #endif
587
+
588
+ #ifdef VERTEX_NORMAL
589
+ {
590
+ // Tangents transform covariantly, so mat3(modelMatrix) is right for any scale — a normal would
591
+ // need its inverse transpose. sign(w) undoes the backward step the last row and column take.
592
+ vec3 tU = mat3(modelMatrix) * ((posU - pos) * sign(positionU.w));
593
+ vec3 tV = mat3(modelMatrix) * ((posV - pos) * sign(positionV.w));
594
+ vec3 n = cross(tU, tV);
595
+ vNormal = n / max(length(n), 1.0e-9);
596
+ }
444
597
  #endif
445
598
 
446
599
  // The scale / rotation / position transform lives on the mesh (modelMatrix), so the
@@ -508,6 +661,10 @@ float grainHash(vec2 p){ return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758
508
661
  float parabola(float x, float k){ return pow(4.0 * x * (1.0 - x), k); }
509
662
  float mapLinear(float v, float a, float b, float c, float d){ return c + (v - a) * (d - c) / (b - a); }
510
663
 
664
+ #ifdef DISSOLVE
665
+ ${dissolveChunk}
666
+ #endif
667
+
511
668
  ${colorFns}
512
669
 
513
670
  // Striations: a subtle high-frequency simplex-noise grain ADDED to the
@@ -546,6 +703,10 @@ vec3 surfaceStreaks(vec2 uv, vec3 color, float crease){
546
703
  }
547
704
 
548
705
  void main(){
706
+ #ifdef DISSOLVE
707
+ // The disintegration front: drop the chunks it has already eaten, before any shading work.
708
+ if (dissolved(vUv, vClipPosition.xy / max(vClipPosition.w, 1.0e-6) * 0.5 + 0.5)) discard;
709
+ #endif
549
710
  // crease: a foreshortening / fold detector from the screen-space uv derivative.
550
711
  // It drives BOTH the roundness shading and where the streaks appear — this is what
551
712
  // gives the wave its thickness without any normal-based lighting.
@@ -661,9 +822,370 @@ void main(){
661
822
  #endif
662
823
  }
663
824
  `;
825
+ const glassFragmentShader = `
826
+ #define MAX_COLORS 8
827
+ #define MAX_MESH_POINTS 8
828
+ #define MAX_LIGHTS 8
829
+ #define PI 3.14159265359
830
+
831
+ ${simplex2d}
832
+
833
+ ${colorUniforms}
834
+ uniform sampler2D uBackdrop; // everything drawn behind this wave, in screen space
835
+ uniform vec2 uResolution;
836
+ uniform float uGlassStrength; // peak bend at the silhouette, in pixels
837
+ uniform float uGlassChroma;
838
+ uniform float uGlassFrost;
839
+ uniform float uGlassSpec;
840
+ uniform float uGlassVibrancy;
841
+ uniform float uGlassTint;
842
+ uniform float uGlassRimPower;
843
+ uniform vec3 uClearColor; // the page behind a transparent scene
844
+ uniform float uGlassPath; // HALF the optical path at normal incidence
845
+ uniform float uGlassDensity; // absorption coefficient
846
+ uniform float uGlassRim;
847
+ uniform float uGlassIrid;
848
+ uniform float uGlassFilmNm;
849
+ uniform float uGlassIor;
850
+ uniform sampler2D uLayers; // glass layers covering this pixel, 1/8 each
851
+ uniform float uGlassLayerGain;
852
+ uniform float uGlassFusion; // droplet merge: bend along the MERGED silhouette, not each normal
853
+ uniform float uGlassCaustic;
854
+ uniform vec3 uViewAxis; // world-space axis from the surface TOWARD an orthographic camera
855
+ uniform float uGlassRipple; // liquid: how hard the travelling waves tilt the normal
856
+ uniform float uGlassRippleScale; // waves per world unit
857
+ uniform float uGlassFlow; // rad/s
858
+ uniform float uTime;
859
+ uniform float uAmbient;
860
+ uniform int uNumLights;
861
+ uniform vec3 uLightPos[MAX_LIGHTS];
862
+ uniform vec3 uLightColor[MAX_LIGHTS];
863
+ uniform float uLightIntensity[MAX_LIGHTS];
864
+ uniform float uEdgeFeather;
865
+ uniform float uEdgeFade;
866
+
867
+ varying vec2 vUv;
868
+ varying vec3 vWorldPos;
869
+ varying vec3 vViewDir;
870
+ varying vec4 vClipPosition;
871
+ #ifdef VERTEX_NORMAL
872
+ varying vec3 vNormal;
873
+ #endif
874
+
875
+ #ifdef DISSOLVE
876
+ ${dissolveChunk}
877
+ #endif
878
+
879
+ ${colorFns}
880
+
881
+ // LIQUID: four travelling trig waves added to the normal as a gradient. Trig rather than scrolled
882
+ // noise on purpose — a scrolled texture drifts one way and reads as a conveyor belt, where crossing
883
+ // waves interfere, which is what water does. The temporal frequencies are integer multiples of one
884
+ // phase so a loop that closes for the motion closes for the water, and the four spatial vectors are
885
+ // incommensurate so the pattern does not visibly repeat.
886
+ vec3 rippleNormal(vec3 N, vec3 p){
887
+ float ph = uTime * uGlassFlow;
888
+ vec3 k1 = vec3( 1.00, 0.62, 0.31);
889
+ vec3 k2 = vec3(-0.54, 1.13, 0.47);
890
+ vec3 k3 = vec3( 0.36, -0.82, 1.07);
891
+ vec3 k4 = vec3(-1.18, -0.33, 0.72);
892
+ vec3 g = vec3(0.0);
893
+ g += k1 * cos(dot(p, k1) * uGlassRippleScale + ph);
894
+ g += k2 * cos(dot(p, k2) * uGlassRippleScale - ph * 2.0 + 1.7) * 0.65;
895
+ g += k3 * cos(dot(p, k3) * uGlassRippleScale + ph * 3.0 + 3.9) * 0.42;
896
+ g += k4 * cos(dot(p, k4) * uGlassRippleScale - ph + 2.6) * 0.55;
897
+ return normalize(N + g * uGlassRipple * 0.16);
898
+ }
899
+
900
+ // Thin-film interference, tinting only what BOUNCES — reflection, rim and specular. Colouring the
901
+ // transmission too reads as dye rather than as a film on the surface.
902
+ vec3 thinFilm(float ndv){
903
+ float s2 = (1.0 - ndv * ndv) / max(uGlassIor * uGlassIor, 1.0e-4);
904
+ float cosT = sqrt(max(1.0 - s2, 0.0));
905
+ vec3 phase = 6.2831853 * (2.0 * uGlassIor * uGlassFilmNm * cosT) / vec3(650.0, 550.0, 440.0);
906
+ return mix(vec3(1.0), 0.5 + 0.5 * cos(phase), clamp(uGlassIrid, 0.0, 1.0));
907
+ }
908
+
909
+ // One frosted tap set, taken AT the already-refracted position so the blur rides the bend instead
910
+ // of sitting flat underneath it. Five taps is enough at these radii; more just costs fill.
911
+ // The backdrop is captured OPAQUE, cleared to the page colour, so a sample is simply the colour
912
+ // behind the glass. It used to be captured transparent and composited over the page here, which
913
+ // left the result at the mercy of what alpha a render target hands back — and the two backends
914
+ // disagree about that.
915
+ vec3 backdropAt(vec2 uv){
916
+ return texture2D(uBackdrop, uv).rgb;
917
+ }
918
+
919
+ // Droplet fusion. Two sheets passing close should behave like one blob of something viscous rather
920
+ // than two objects overlapping — and the trick that sells it is not the shape but the DIRECTION of
921
+ // the bend: in the neck between them the surface normal has to rotate smoothly from one rim to the
922
+ // other, or the refraction tears between two centres.
923
+ //
924
+ // The 2D original merges signed-distance fields with a smooth minimum and takes the direction from
925
+ // the gradient of the merged field. There is no SDF here, but the layer-coverage buffer is the same
926
+ // thing in screen space once it is smeared: blur it and two nearby silhouettes bridge, exactly as a
927
+ // smooth minimum bridges two distance fields. Its gradient is then the merged normal, for free.
928
+ float coverageField(vec2 uv){
929
+ vec2 r = 9.0 / uResolution;
930
+ float f = texture2D(uLayers, uv).r * 4.0;
931
+ f += texture2D(uLayers, uv + vec2(r.x, 0.0)).r * 2.0;
932
+ f += texture2D(uLayers, uv - vec2(r.x, 0.0)).r * 2.0;
933
+ f += texture2D(uLayers, uv + vec2(0.0, r.y)).r * 2.0;
934
+ f += texture2D(uLayers, uv - vec2(0.0, r.y)).r * 2.0;
935
+ f += texture2D(uLayers, uv + r).r;
936
+ f += texture2D(uLayers, uv - r).r;
937
+ f += texture2D(uLayers, uv + vec2(r.x, -r.y)).r;
938
+ f += texture2D(uLayers, uv + vec2(-r.x, r.y)).r;
939
+ return f / 16.0;
940
+ }
941
+
942
+ // Frosting is SCATTER, not blur. Blurring one lookup smears whatever that single ray happened to
943
+ // hit, which reads as a dirty window; spreading real samples over a cone is what loses the image
944
+ // behind while keeping the light. Eleven samples on a golden-angle spiral, spread by sqrt(i/N) so
945
+ // they cover the disc evenly, rotated per PIXEL (hashed from the coordinate, not from time — a
946
+ // time-varying rotation boils) so the pattern does not tile.
947
+ #define FROST_SAMPLES 11
948
+ float frostRotation(vec2 p){
949
+ return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453) * 6.2831853;
950
+ }
951
+ vec3 frostSample(vec2 uv, float radiusPx){
952
+ float rot = frostRotation(gl_FragCoord.xy);
953
+ vec2 r = radiusPx / uResolution;
954
+ vec3 acc = vec3(0.0);
955
+ for (int i = 0; i < FROST_SAMPLES; i++) {
956
+ float t = (float(i) + 0.5) / float(FROST_SAMPLES);
957
+ float a = rot + float(i) * 2.399963; // golden angle
958
+ acc += backdropAt(uv + vec2(cos(a), sin(a)) * r * sqrt(t));
959
+ }
960
+ return acc / float(FROST_SAMPLES);
961
+ }
962
+
963
+ // The shading normal for a raw — interpolated, unnormalised — surface normal: unit length, faced
964
+ // toward the viewer (the orientation the screen-derivative normal always had, so a thin sheet
965
+ // bends the same way whichever face is in front), then rippled. A zero-length input faces the
966
+ // camera and bends nothing. Shared by the shading path and the caustic's finite differences, so
967
+ // the two can never disagree about the surface.
968
+ vec3 glassShadingNormal(vec3 rawN, vec3 pos, vec3 V, out vec3 flatN){
969
+ float nLen = length(rawN);
970
+ vec3 N = nLen > 1.0e-6 ? rawN / nLen : V;
971
+ if (dot(N, V) < 0.0) N = -N;
972
+ flatN = N;
973
+ if (uGlassRipple > 0.001) N = rippleNormal(N, pos);
974
+ return N;
975
+ }
976
+
977
+ // The refraction offset, in pixels, of the surface at (rawN, pos) — before droplet fusion.
978
+ vec2 glassOffset(vec3 rawN, vec3 pos, vec3 V){
979
+ vec3 flatN;
980
+ vec3 N = glassShadingNormal(rawN, pos, V, flatN);
981
+ float rim = pow(1.0 - abs(dot(N, V)), max(uGlassRimPower, 0.001));
982
+ // The ripple is fed into the OFFSET as well as the normal — see main().
983
+ vec2 dir = -(N.xy + (N.xy - flatN.xy) * 2.0);
984
+ return dir * mix(rim, 1.0, uGlassRipple * 0.25) * uGlassStrength;
985
+ }
986
+
987
+ void main(){
988
+ #ifdef DISSOLVE
989
+ if (dissolved(vUv, vClipPosition.xy / max(vClipPosition.w, 1.0e-6) * 0.5 + 0.5)) discard;
990
+ #endif
991
+ // ORTHOGRAPHIC camera: every ray is parallel, so the view direction is the camera's forward axis,
992
+ // NOT a per-fragment vector to the eye. vViewDir (cameraPosition - world) is the perspective form
993
+ // and under ortho it fans out across the frame — using it swings the rim band and the specular
994
+ // across the ribbon as if the camera were inches away. Fed as a uniform rather than dug out of
995
+ // viewMatrix, because the TSL twin cannot index a matrix node and the two must not diverge.
996
+ vec3 V = normalize(uViewAxis);
997
+ #ifdef VERTEX_NORMAL
998
+ // The interpolated vertex normal: smooth across the mesh, where dFdx of the world position is
999
+ // constant per triangle and a 120 px refraction turned every triangle edge into a seam.
1000
+ vec3 rawN = vNormal;
1001
+ #else
1002
+ vec3 rawN = cross(dFdx(vWorldPos), dFdy(vWorldPos)); // per triangle: the caustic sees no curvature
1003
+ #endif
1004
+ vec3 flatN; // the geometric normal, kept so the ripple's CONTRIBUTION can be isolated below
1005
+ vec3 N = glassShadingNormal(rawN, vWorldPos, V, flatN);
1006
+ // The rim band, in 3D: 1 where the surface grazes the eye, 0 where it faces us. This is the same
1007
+ // curve the 2D work bakes as a rounded-rect inset, except it comes from the geometry, so it
1008
+ // follows every fold and twist without anything being authored.
1009
+ float rim = pow(1.0 - abs(dot(N, V)), max(uGlassRimPower, 0.001));
1010
+
1011
+ vec2 sUv = gl_FragCoord.xy / max(uResolution, vec2(1.0));
1012
+ // The ripple is fed into the OFFSET as well as the normal. Tilting a normal where the surface
1013
+ // faces the camera barely changes N·V, so on a broad flat ribbon the ripple was nearly invisible
1014
+ // and only showed on the twisting flanks; displacing there costs nothing and reads everywhere.
1015
+ vec2 dir = -(N.xy + (N.xy - flatN.xy) * 2.0);
1016
+ if (uGlassFusion > 0.001) {
1017
+ // Gradient of the smeared coverage, by finite difference. Pointing INTO the merged shape is the
1018
+ // same convention the geometric normal uses, so the two blend without flipping the bend.
1019
+ vec2 g = 9.0 / uResolution;
1020
+ vec2 grad = vec2(
1021
+ coverageField(sUv + vec2(g.x, 0.0)) - coverageField(sUv - vec2(g.x, 0.0)),
1022
+ coverageField(sUv + vec2(0.0, g.y)) - coverageField(sUv - vec2(0.0, g.y))
1023
+ );
1024
+ if (dot(grad, grad) > 1.0e-8) dir = mix(dir, normalize(grad), clamp(uGlassFusion, 0.0, 1.0));
1025
+ }
1026
+ vec2 offPx = dir * mix(rim, 1.0, uGlassRipple * 0.25) * uGlassStrength;
1027
+ vec2 off = offPx / max(uResolution, vec2(1.0));
1028
+
1029
+ // Dispersion: the same bend at three slightly different scales, one per channel.
1030
+ vec2 uvR = sUv + off * (1.0 + uGlassChroma * 0.2);
1031
+ vec2 uvG = sUv + off * (1.0 + uGlassChroma * 0.1);
1032
+ vec2 uvB = sUv + off;
1033
+ vec3 col = vec3(backdropAt(uvR).r, backdropAt(uvG).g, backdropAt(uvB).b);
1034
+ // CAUSTICS. A caustic is not a decal painted near the glass — it is what happens when the
1035
+ // refraction map compresses, so neighbouring rays land on top of each other and energy piles up.
1036
+ // The sampling map here is m(p) = p + offPx(p), so its Jacobian is the identity plus the offset's
1037
+ // screen-space derivative, and brightness goes as 1/|det J|: below 1 where the map compresses,
1038
+ // above 1 where it spreads. The derivatives are already free in a fragment shader, which is why
1039
+ // this needs no extra pass — it is the gather form of the usual light-space splat.
1040
+ if (uGlassCaustic > 0.001) {
1041
+ // One-pixel forward differences of the offset, re-evaluated at the neighbouring pixel's normal
1042
+ // and position. Those come from dFdx of the INTERPOLATED normal (and of the position, for the
1043
+ // ripple), which is linear across a triangle, so every backend and derivative mode agrees on
1044
+ // it to the bit — where dFdx of the offset itself, a nonlinear function of the normal, is
1045
+ // implementation-defined (coarse or fine) and split the two backends at every steep fold.
1046
+ // It works at all because the normal is interpolated: derived from dFdx of the position it was
1047
+ // constant per quad, its own derivative identically zero, and the caustic needed a separate
1048
+ // normal buffer and a 3 px stencil to get a second difference out of a first-difference normal.
1049
+ // A ±3 px central difference — the baseline the normal-buffer stencil had. The map's fold is a
1050
+ // pole in 1/|det J|, and a one-pixel difference lands so close to it that rounding alone moved
1051
+ // the bright band between the two backends; six pixels of baseline keep them on the same side.
1052
+ vec3 dNx = dFdx(rawN) * 3.0;
1053
+ vec3 dNy = dFdy(rawN) * 3.0;
1054
+ vec3 dPx = dFdx(vWorldPos) * 3.0;
1055
+ vec3 dPy = dFdy(vWorldPos) * 3.0;
1056
+ vec2 dOdx = (glassOffset(rawN + dNx, vWorldPos + dPx, V)
1057
+ - glassOffset(rawN - dNx, vWorldPos - dPx, V)) / 6.0;
1058
+ vec2 dOdy = (glassOffset(rawN + dNy, vWorldPos + dPy, V)
1059
+ - glassOffset(rawN - dNy, vWorldPos - dPy, V)) / 6.0;
1060
+ float detJ = (1.0 + dOdx.x) * (1.0 + dOdy.y) - dOdy.x * dOdx.y;
1061
+ // The floor matters: at a fold the map folds too, det passes through zero, and the true
1062
+ // brightness there is infinite. Real caustics are bounded by the width of the light source, so
1063
+ // clamping is physical rather than a fudge — it is what stops a cusp blowing out to white.
1064
+ float gain = clamp(1.0 / max(abs(detJ), 0.12), 0.0, 6.0);
1065
+ col *= mix(1.0, gain, clamp(uGlassCaustic, 0.0, 1.0));
1066
+ }
1067
+
1068
+ // Radius grows with the SQUARE of frost, the way a scattering lobe does: gentle at the low end
1069
+ // where you want a hint of ground glass, and genuinely opaque by the top. Gated on the radius in
1070
+ // PIXELS, not on the knob: under half a pixel the eleven taps average back to the bilinear
1071
+ // sample they surround, so the default 0.08 (0.29 px) was paying for a blur nobody could see.
1072
+ float frostRadius = uGlassFrost * uGlassFrost * 46.0;
1073
+ if (frostRadius > 0.5) {
1074
+ // One RGB gather at the green offset. Gathering per channel tripled the taps for a dispersion
1075
+ // the scatter itself washes out at any radius where the frost is visible at all.
1076
+ col = mix(col, frostSample(uvG, frostRadius), clamp(uGlassFrost, 0.0, 1.0));
1077
+ }
1078
+
1079
+ // ---- the material itself ----
1080
+ // This is what makes glass a MATERIAL and not a window. The ribbon's own palette is treated as
1081
+ // transmitted light, absorbed over the sheet's own thickness: 2·path at normal incidence, longer
1082
+ // as the surface turns away. A single-sided ribbon has no back face to measure against, so the
1083
+ // chord is analytic. The result survives with nothing behind it — the page is simply what the
1084
+ // colour is absorbed OUT of.
1085
+ float ndv = clamp(abs(dot(N, V)), 0.02, 1.0);
1086
+ vec3 lit = applyColorGrade(waveBaseColor(vUv));
1087
+ // Thickness. The analytic term is the chord through one sheet; the layer count adds the folds
1088
+ // stacked behind this fragment, which opaque drawing would otherwise throw away.
1089
+ float layers = max(texture2D(uLayers, sUv).r * 8.0, 1.0);
1090
+ float chord = 2.0 * uGlassPath * pow(ndv, 0.40) * (1.0 + uGlassLayerGain * (layers - 1.0));
1091
+ float trans = 1.0 - exp(-uGlassDensity * chord);
1092
+ // True per-channel Beer-Lambert. The palette is read as what the sheet LETS THROUGH, so its dark
1093
+ // channels absorb and its bright ones pass: pink glass over cream paper stays pink instead of
1094
+ // washing to cream. The alternative — normalising to the brightest channel and tinting — can only
1095
+ // ever lighten, so deep glass came out as a pale film however far its thickness was pushed.
1096
+ // The palette sets the HUE of what gets through; density sets how much is stopped. Every channel
1097
+ // absorbs something (the 0.9 keeps the floor above zero), so thickness DARKENS as well as tints —
1098
+ // which is the part that reads as a solid volume. Deriving absorption straight from the palette
1099
+ // instead fails on this library's bright palettes: 1-lit is then near zero, nothing is absorbed,
1100
+ // and thick glass comes out as pale as thin.
1101
+ vec3 hue = lit / max(max(lit.r, max(lit.g, lit.b)), 0.001);
1102
+ vec3 sigma = uGlassDensity * (1.0 - hue * 0.9);
1103
+ // Dispersion in the BODY, not only in the backdrop lens: each channel travels a slightly
1104
+ // different path, so a thick edge fringes even with nothing behind the sheet to bend. Without
1105
+ // this, glassChroma did nothing at all on a standalone wave.
1106
+ vec3 chordRGB = chord * (1.0 + vec3(uGlassChroma * 0.12, 0.0, -uGlassChroma * 0.12));
1107
+ vec3 transmittance = exp(-sigma * chordRGB);
1108
+ col = col * mix(vec3(1.0), transmittance, clamp(uGlassTint, 0.0, 1.0));
1109
+
1110
+ vec3 film = thinFilm(ndv);
1111
+
1112
+ // Fresnel: at a grazing angle the sheet stops transmitting and starts mirroring. With nothing to
1113
+ // mirror it reflects the page, which is exactly what glass on paper does.
1114
+ float f0 = pow((uGlassIor - 1.0) / (uGlassIor + 1.0), 2.0);
1115
+ float F = f0 + (1.0 - f0) * pow(1.0 - ndv, 5.0);
1116
+ // The reflection weight is deliberately LOW. Over a dark room the bounce is the only thing
1117
+ // describing the solid and wants to dominate; over bright paper the same weight turns the whole
1118
+ // sheet white and the colour we just absorbed is thrown away.
1119
+ col = mix(col, mix(uClearColor, vec3(1.0), 0.35) * film, F * (0.18 + uGlassIrid * 0.4));
1120
+
1121
+ // Rim. The window is WIDE on purpose: a band that only covers the last few degrees before
1122
+ // edge-on is thinner than a pixel on a ribbon, and a knob nothing responds to is not subtle, it
1123
+ // is broken. A narrow darker band just inside gives the edge a lip rather than a glow.
1124
+ col = mix(col, film, smoothstep(mix(0.62, 0.42, uGlassIrid), 1.0, 1.0 - ndv) * uGlassRim);
1125
+ col *= 1.0 - smoothstep(0.62, 0.86, 1.0 - ndv) * 0.10;
1126
+
1127
+ // TWO keys, and a wide lobe. One overhead light never reaches a surface whose normals are all
1128
+ // horizontal — a twisted ribbon has plenty of those — and no exponent fixes that, so a second,
1129
+ // low key near the view axis fills them in.
1130
+ vec3 KEY = normalize(vec3(-0.30, 0.86, 0.42));
1131
+ vec3 KEY_FILL = normalize(vec3(0.42, 0.16, 0.89));
1132
+ vec3 mirror = reflect(-V, N);
1133
+ float lobe = pow(max(dot(mirror, KEY), 0.0), 40.0)
1134
+ + 0.55 * pow(max(dot(mirror, KEY_FILL), 0.0), 40.0);
1135
+ float spec = (lobe + rim * 0.25) * uGlassSpec;
1136
+ col += lobe * uGlassSpec * 0.35 * film;
1137
+
1138
+ float lumaV = dot(col, vec3(0.299, 0.587, 0.114));
1139
+ // Over a dark backdrop the glint adds; over a bright one it darkens. Without this the rim simply
1140
+ // disappears on the warm paper most of these scenes use.
1141
+ float darkBlend = smoothstep(0.25, 0.7, lumaV);
1142
+ col = max(mix(col + spec, col * (1.0 - spec), darkBlend), 0.0);
1143
+ // Vibrancy: pull the interior toward mid-grey — the haze that says "glass" rather than "hole".
1144
+ col += (0.5 - lumaV) * uGlassVibrancy;
1145
+
1146
+ // Glass draws OPAQUE, so alpha never reaches a blend: a soft edge has to fade toward the UNBENT
1147
+ // backdrop instead. Scaling the colour by alpha, as the blended themes do under
1148
+ // PREMULTIPLIED_ALPHA, faded the feather toward black and drew a hard dark line along every
1149
+ // silhouette. Opacity folds into the same fade — for a sheet with nothing to blend against,
1150
+ // "half opaque" can only mean "half as much bending".
1151
+ float fade = clamp(uOpacity, 0.0, 1.0);
1152
+ if (uEdgeFeather > 0.0) {
1153
+ float e = min(min(vUv.x, 1.0 - vUv.x), min(vUv.y, 1.0 - vUv.y));
1154
+ fade *= smoothstep(0.0, uEdgeFeather, e);
1155
+ }
1156
+ col = mix(backdropAt(sUv), col, fade);
1157
+ gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
1158
+ }
1159
+ `;
1160
+ const glassLayerFragmentShader = `
1161
+ #define MAX_COLORS 8
1162
+ #define MAX_MESH_POINTS 8
1163
+ #define MAX_LIGHTS 8
1164
+
1165
+ ${simplex2d}
1166
+
1167
+ ${colorUniforms}
1168
+ uniform vec2 uResolution;
1169
+ uniform float uTime;
1170
+
1171
+ varying vec2 vUv;
1172
+ varying vec4 vClipPosition;
1173
+
1174
+ #ifdef DISSOLVE
1175
+ ${dissolveChunk}
1176
+ #endif
1177
+
1178
+ void main(){
1179
+ #ifdef DISSOLVE
1180
+ if (dissolved(vUv, vClipPosition.xy / max(vClipPosition.w, 1.0e-6) * 0.5 + 0.5)) discard;
1181
+ #endif
1182
+ gl_FragColor = vec4(vec3(0.125), 1.0);
1183
+ }
1184
+ `;
664
1185
  const lineFragmentShader = `
665
1186
  #define MAX_COLORS 8
666
1187
  #define MAX_MESH_POINTS 8
1188
+ #define MAX_LIGHTS 8
667
1189
  #define PI 3.14159265359
668
1190
 
669
1191
  ${simplex2d}
@@ -672,7 +1194,35 @@ ${colorUniforms}
672
1194
  uniform float uLineAmount; // default 425
673
1195
  uniform float uLineThickness; // default 1
674
1196
  uniform float uLineDerivativePower; // default 0.95
675
- uniform float uMaxWidth; // default 1232
1197
+ uniform float uLineDepthFade; // 1 = the original hardcoded recede, 0 = flat/graphic
1198
+ // Lighting (optional). The line theme is otherwise UNLIT: a strand's colour comes from its uv alone,
1199
+ // so it is the same tone wherever the surface turns, which is what makes a dense wireframe read as a
1200
+ // printed pattern rather than as an object. This shades it with the same derivative normal, lights
1201
+ // and crease the solid theme uses, so a single strand brightens and darkens ALONG its own length as
1202
+ // the ribbon curves — which is the whole difference between a drawing and a lit form.
1203
+ #ifdef LINE_LIGHT
1204
+ uniform float uLineLight; // 0 = flat (the theme as it was), 1 = fully shaded
1205
+ // Round section and glint are CONSTANTS, not knobs: shading a flat stripe barely reads, so lighting
1206
+ // and rounding only make sense together — one control, tuned once.
1207
+ #define LINE_ROUND 1.2
1208
+ #define LINE_GLINT 1.5
1209
+ uniform float uAmbient;
1210
+ uniform int uNumLights;
1211
+ uniform vec3 uLightPos[MAX_LIGHTS];
1212
+ uniform vec3 uLightColor[MAX_LIGHTS];
1213
+ uniform float uLightIntensity[MAX_LIGHTS];
1214
+ varying vec3 vWorldPos;
1215
+ varying vec3 vViewDir;
1216
+ #endif
1217
+ #ifdef EDGE_FEATHER
1218
+ uniform float uEdgeFeather; // softness of the ribbon's two ENDS (shared with the solid theme)
1219
+ #endif
1220
+ #ifdef LINE_CLEAR_GAPS
1221
+ uniform float uLineGapOpacity; // how much page colour the gaps between strands carry (0 = clear)
1222
+ #endif
1223
+ #ifdef LINE_SHARP
1224
+ uniform float uLineSharpness; // 0..1 — steepen the stripe profile toward a hard duty cycle
1225
+ #endif
676
1226
  // Cross-wise rungs (optional) — behind RUNGS so a wave without them compiles the same program.
677
1227
  #ifdef RUNGS
678
1228
  uniform float uRungAmount; // frequency across the ribbon (rungs ≈ amount / π)
@@ -691,10 +1241,19 @@ varying float vPointerFall; // falloff × presence, written by the vertex sha
691
1241
 
692
1242
  ${colorFns}
693
1243
 
1244
+ #ifdef DISSOLVE
1245
+ ${dissolveChunk}
1246
+ #endif
1247
+
694
1248
  void main(){
1249
+ #ifdef DISSOLVE
1250
+ // The disintegration front: drop the chunks it has already eaten (see dissolveChunk).
1251
+ if (dissolved(vUv, vClipPosition.xy / max(vClipPosition.w, 1.0e-6) * 0.5 + 0.5)) discard;
1252
+ #endif
695
1253
  // Same 2D palette sample + colour ops as the solid theme.
696
1254
  vec3 color = applyColorGrade(waveBaseColor(vUv));
697
1255
 
1256
+
698
1257
  #ifdef POINTER_FX
699
1258
  color = hueShift(color, radians(uPointerHue) * vPointerFall);
700
1259
  color *= 1.0 + uPointerLighten * vPointerFall;
@@ -702,32 +1261,147 @@ void main(){
702
1261
 
703
1262
  // Carve into fine lengthwise strands; thickness from the screen-space uv derivative.
704
1263
  vec2 dy = dFdy(vUv);
705
- float lineThickness = uLineThickness * pow(abs(dy.x * uMaxWidth), uLineDerivativePower);
1264
+ // 1232 is a fixed reference width, not a knob: the old uMaxWidth uniform only ever multiplied the
1265
+ // derivative before the power, and pow(a*b, p) = pow(a, p)·pow(b, p) — so every value of it was
1266
+ // reachable by scaling lineThickness instead. See normalizeWave, which migrates the old field.
1267
+ float lineThickness = uLineThickness * pow(abs(dy.x * 1232.0), uLineDerivativePower);
706
1268
  #ifdef POINTER_FX
707
1269
  lineThickness *= clamp(1.0 - uPointerThin * vPointerFall, 0.0, 1.0); // wireframe: taper strands
708
1270
  #endif
1271
+ // Each stripe family's per-pixel RATE — how fast its |sin| argument moves — and the DUTY CYCLE it
1272
+ // averages to, which is the fraction of a period that is strand. Both are needed below: once a
1273
+ // period is finer than a pixel, sampling |sin| at one arbitrary point per period is meaningless
1274
+ // (and is where two backends' derivative estimates diverge), so the coverage fades to the tone the
1275
+ // strands actually average to. That is also what a compressed region should look like: a solid
1276
+ // tone, not noise.
1277
+ float lineRate = uLineAmount * fwidth(vUv.x);
1278
+ #ifdef LINE_LIGHT
1279
+ {
1280
+ // The same derivative normal the solid theme uses — the mesh is finely subdivided, so it is
1281
+ // smooth enough to shade with. Flipped toward the camera because a ribbon is double-sided.
1282
+ vec3 N = normalize(cross(dFdx(vWorldPos), dFdy(vWorldPos)));
1283
+ vec3 Vd = normalize(vViewDir);
1284
+ if (dot(N, Vd) < 0.0) N = -N;
1285
+ // ROUND STRANDS. Until here a strand is a MASK — a stripe painted on a flat sheet, with no
1286
+ // cross-section of its own, which is why a dense wireframe reads as print however it is lit: a
1287
+ // printed line has no side to catch a highlight. Bending the normal ACROSS each stripe turns
1288
+ // every strand into a half-round filament, so the light runs along one and not its neighbour and
1289
+ // the bundle reads as combed thread rather than as hatching.
1290
+ //
1291
+ // The across-vector is the world direction of increasing uv.x, recovered from the screen-space
1292
+ // derivatives by least squares (the chain rule the other way round): it is the axis to tilt
1293
+ // about, and it is what makes the shading follow the strands wherever the surface turns.
1294
+ {
1295
+ vec2 gu = vec2(dFdx(vUv.x), dFdy(vUv.x));
1296
+ float gg = dot(gu, gu);
1297
+ if (gg > 1.0e-12) {
1298
+ vec3 across = (dFdx(vWorldPos) * gu.x + dFdy(vWorldPos) * gu.y) / gg;
1299
+ across = normalize(across - N * dot(across, N)); // keep it in the surface
1300
+ // Signed position across the strand: 0 at its crest, ±1 at its edges.
1301
+ float sAcross = clamp(sin(vUv.x * uLineAmount) / max(lineThickness, 1.0e-4), -1.0, 1.0);
1302
+ N = normalize(N + across * sAcross * LINE_ROUND);
1303
+ if (dot(N, Vd) < 0.0) N = -N;
1304
+ }
1305
+ }
1306
+ float facing = abs(dot(N, Vd));
1307
+ // Base shading: grazing parts of the surface fall away toward shadow, the facing body keeps its
1308
+ // colour. This alone is what makes a strand shade along its length.
1309
+ vec3 lit = color * mix(0.08, 1.0, facing);
1310
+ for (int i = 0; i < MAX_LIGHTS; i++) {
1311
+ if (i >= uNumLights) break;
1312
+ vec3 L = normalize(uLightPos[i] - vWorldPos);
1313
+ vec3 lc = uLightColor[i] * uLightIntensity[i];
1314
+ lit += color * max(dot(N, L), 0.0) * lc * 0.5;
1315
+ // A tight specular, which on a combed surface is the glint that runs along one strand and not
1316
+ // its neighbour — the thing that reads as filament rather than as print.
1317
+ lit += pow(max(dot(N, normalize(L + Vd)), 0.0), 48.0) * lc * LINE_GLINT;
1318
+ }
1319
+ lit *= 0.55 + clamp(uAmbient, 0.0, 1.0);
1320
+ color = mix(color, lit, clamp(uLineLight, 0.0, 1.0));
1321
+ }
1322
+ #endif
1323
+
709
1324
  float a = abs(sin(vUv.x * uLineAmount));
710
1325
  a = smoothstep(lineThickness, 0.0, a);
711
-
1326
+ // NOTE there is deliberately no duty-cycle fallback for the LENGTHWISE family, though the rungs
1327
+ // below have one. Its threshold is lineThickness, which is itself built from dFdy(vUv).x, so a
1328
+ // fallback keyed on it would add a SECOND derivative for the two backends to disagree about —
1329
+ // measured, it made cross-backend agreement worse, not better. The rungs' threshold is a plain
1330
+ // pixel width, which is why the same trick works there.
1331
+ float rungRate = 0.0; // the cross-wise family's rate / duty; 0 unless rungs are compiled in
1332
+ float dutyRung = 0.0;
712
1333
  #ifdef RUNGS
713
1334
  // Rungs: the same carve at constant uv.y instead of uv.x, so this family runs ACROSS the ribbon
714
1335
  // where the one above runs along it — together they read as a ladder. Width comes from fwidth()
715
1336
  // rather than the lengthwise term's dFdy(vUv).x, which is the derivative of the wrong axis for
716
1337
  // this direction: |sin| climbs by ~uRungAmount·fwidth(vUv.y) per pixel, so scaling by that keeps
717
1338
  // a rung uRungThickness pixels wide at any zoom or ribbon scale.
718
- float rung = abs(sin(vUv.y * uRungAmount));
719
- a = max(a, smoothstep(uRungThickness * uRungAmount * fwidth(vUv.y), 0.0, rung));
1339
+ rungRate = uRungAmount * fwidth(vUv.y);
1340
+ float rungT = uRungThickness * rungRate;
1341
+ a = max(a, smoothstep(rungT, 0.0, abs(sin(vUv.y * uRungAmount))));
1342
+ dutyRung = 0.63661977 * asin(clamp(rungT * 0.5, 0.0, 1.0));
720
1343
  #endif
721
1344
 
1345
+ #ifdef LINE_SHARP
1346
+ // Harden the stripe: the value above is a SOFT ramp — |sin| feathered over the whole half-period — so
1347
+ // raising uLineThickness widens the strands by fading the gaps out with them, and the surface goes
1348
+ // from pale hairlines straight to flat solid without ever passing through dense ink. Steepening it
1349
+ // about its own midpoint separates the two: uLineThickness becomes the DUTY CYCLE (where the ramp
1350
+ // crosses 0.5) and this becomes the edge, so a wave can be 70% ink with crisp gaps still showing.
1351
+ //
1352
+ // Applied to the MERGED coverage, after the rungs have been folded in, so a cross-wise family
1353
+ // reaches dense ink the same way a lengthwise one does. The floor is the ANALYTIC stripe rate
1354
+ // rather than fwidth() of that merged value, whose derivative is undefined where the two families
1355
+ // swap over.
1356
+ float aaRate = 0.5 * max(lineRate, rungRate);
1357
+ a = clamp((a - 0.5) / max(1.0 - uLineSharpness, aaRate * 1.4) + 0.5, 0.0, 1.0);
1358
+ #endif
1359
+
1360
+ // Sub-pixel rungs: fade to the tone those strands average to (see the note above for why only
1361
+ // this family gets it).
1362
+ a = mix(a, max(a, dutyRung), smoothstep(1.2, 3.0, rungRate));
1363
+
722
1364
  // Depth fade: the wave recedes into the background colour with depth. Watch the
723
1365
  // argument order: clamp(0.0, 1.0, z*6) is a swapped-args trap — it clamps the
724
1366
  // constant 0.0 into [1.0, z*6], i.e. min(1.0, z*6), which (with our ortho clip.z
725
1367
  // range) collapses the whole wave to the background. The correct clamp(z*6, 0, 1)
726
1368
  // gives the proper subtle far-end fade and thin-line look.
727
- float depthFade = clamp(vClipPosition.z * 6.0, 0.0, 1.0);
728
- color = mix(uClearColor, color, a * (1.0 - depthFade));
1369
+ float depthFade = clamp(vClipPosition.z * 6.0, 0.0, 1.0) * uLineDepthFade;
1370
+ float cov = a * (1.0 - depthFade);
1371
+ // Soft ribbon ENDS, exactly as the solid theme fades them (on vUv.y, the length). Without this a
1372
+ // wireframe ribbon stops dead: its end-cap is a flat cross-section that reads as a straight cut
1373
+ // drawn across the strands, which is glaring the moment a ribbon curls back into frame. The 0.1
1374
+ // default matches the solid theme's hardcoded value, so a wave that never set edgeFeather keeps
1375
+ // its old ends — this only ever softens what was already an abrupt stop.
1376
+ #ifdef EDGE_FEATHER
1377
+ cov *= smoothstep(0.0, uEdgeFeather, vUv.y) * (1.0 - smoothstep(1.0 - uEdgeFeather, 1.0, vUv.y));
1378
+ #else
1379
+ cov *= smoothstep(0.0, 0.1, vUv.y) * (1.0 - smoothstep(0.9, 1.0, vUv.y));
1380
+ #endif
1381
+ #ifdef LINE_CLEAR_GAPS
1382
+ // CLEAR GAPS. By default the gaps between strands are painted with the page colour, which makes a
1383
+ // wireframe wave an opaque card: stack two and the front one's gaps hide the back one behind flat
1384
+ // page colour instead of showing it. Here the gaps only carry that colour as far as
1385
+ // uLineGapOpacity and are otherwise transparent, so the strands composite over whatever is really
1386
+ // behind them — the next wave in the stack, a solid wave used as a dark backing, or the page.
1387
+ //
1388
+ // Straight alpha-over, unpremultiplied: the visible colour is the strand and the gap weighted by
1389
+ // their coverages, divided back out by the total so the result is a colour rather than a
1390
+ // premultiplied one (Three's own PREMULTIPLIED_ALPHA step below does that part).
1391
+ float gapA = (1.0 - cov) * uLineGapOpacity;
1392
+ float outA = cov + gapA;
1393
+ // A fully clear gap must not reach the depth buffer, or it would occlude the wave behind it just
1394
+ // as the opaque version did. Strand EDGES keep their partial alpha (and their depth), which is a
1395
+ // pixel either side and exactly what antialiasing them is for.
1396
+ if (outA <= 0.002) discard;
1397
+ color = (color * cov + uClearColor * gapA) / outA;
1398
+ if (uSquared > 0.5) color *= color; // deep "squared" look, now composited not replace-blended
1399
+ gl_FragColor = vec4(color, uOpacity * outA);
1400
+ #else
1401
+ color = mix(uClearColor, color, cov);
729
1402
  if (uSquared > 0.5) color *= color; // deep "squared" look, now composited not replace-blended
730
1403
  gl_FragColor = vec4(color, uOpacity);
1404
+ #endif
731
1405
  #ifdef PREMULTIPLIED_ALPHA
732
1406
  gl_FragColor.rgb *= gl_FragColor.a;
733
1407
  #endif
@@ -978,13 +1652,24 @@ uniform float uTwFreqX, uTwFreqY, uTwFreqZ, uTwPowX, uTwPowY, uTwPowZ;
978
1652
  #ifdef HELIX
979
1653
  uniform float uHelixTurns, uHelixRadius, uHelixRoll, uHelixPhase;
980
1654
  #endif
1655
+ #ifdef PATH
1656
+ uniform sampler2D uPathTex;
1657
+ #endif
981
1658
  #ifdef RADIAL
982
- uniform float uRadialAmount, uRadialArc, uRadialSpread, uRadialRadius, uRadialCenter;
1659
+ uniform float uRadialAmount, uRadialArc, uRadialSpread, uRadialRadius, uRadialCenter, uRadialCone, uRadialSwirl;
983
1660
  #endif
984
1661
  uniform mat4 uShedModel; // the wave's matrixWorld (deformed LOCAL → world)
985
1662
  uniform float uShedSpeed, uShedSeed;
986
1663
  ${waveShapeChunk}
987
1664
 
1665
+ // The owning wave's disintegration front, mirrored the same way, so a mote peels off exactly where
1666
+ // and when the surface under it crumbles. uDissolveDust is the particle-only knob (0 = ignore the
1667
+ // front and free-run on uLife, as a field with no dissolve always has).
1668
+ #ifdef DISSOLVE
1669
+ ${dissolveChunk}
1670
+ uniform float uDissolveDust;
1671
+ #endif
1672
+
988
1673
  // The cursor. Same chunk the ribbon uses, mirrored onto this material in ParticleField.configure(),
989
1674
  // and behind the same POINTER_FX gate — a wave with no hover field compiles the point program it
990
1675
  // always did. uPartShove is the one particle-only knob (see the two samples in main).
@@ -995,7 +1680,8 @@ uniform float uPartShove; // how hard the cursor shoves dust that has already dr
995
1680
 
996
1681
  varying float vAlpha;
997
1682
  varying vec3 vColor;
998
- varying vec2 vDir; // screen-space motion direction (for the streak sprite)
1683
+ varying vec2 vDir; // screen-space motion direction (for the streak sprite)
1684
+ varying float vSeed; // this particle's seed (the square sprite cuts its own shard from it)
999
1685
 
1000
1686
  const float TAU = 6.28318530718;
1001
1687
 
@@ -1024,6 +1710,36 @@ void main(){
1024
1710
  WaveShape ws = waveShape(base, aUv, ts, loopOff);
1025
1711
  vec3 origin = (uShedModel * vec4(ws.pos, 1.0)).xyz;
1026
1712
  vec3 outward = normalize(origin - uCenter + vec3(1e-4));
1713
+ // (DISSOLVE may re-aim this below — see the debris sweep.)
1714
+ #ifdef DISSOLVE
1715
+ // Pinned to the wave's dissolve front: this mote IS the chunk of surface that just left, so it
1716
+ // does not exist until the front reaches its patch, then peels off and drifts on from there.
1717
+ // age becomes its progress past the front rather than a free-running clock — which is what makes
1718
+ // the dust and the holes in the ribbon one event instead of two effects that happen to overlap.
1719
+ {
1720
+ float band = max(uDissolveBand, 1.0e-3);
1721
+ // Stagger: nudge each mote's own front, and give it its own peel rate, so a band does not lift
1722
+ // off as one flat sheet.
1723
+ vec4 dClip = projectionMatrix * viewMatrix * vec4(origin, 1.0);
1724
+ vec2 dNdc = dClip.xy / max(dClip.w, 1.0e-6) * 0.5 + 0.5;
1725
+ float c = dissolveCoord(aUv, dNdc) + (aRnd.x - 0.5) * band * 0.9;
1726
+ float front = uDissolveAmount * (1.0 + band);
1727
+ float peel = clamp((front - c) / (band * (0.4 + aRnd.y * 1.2)), 0.0, 1.0);
1728
+ age = mix(age, peel, uDissolveDust);
1729
+ // Visible from the moment the front takes it, then a long tail out as it travels.
1730
+ float f = smoothstep(0.0, 0.06, peel) * (1.0 - smoothstep(0.55, 1.0, peel));
1731
+ fade = mix(fade, f, uDissolveDust);
1732
+ // Debris is thrown along the sweep, AWAY from the part still standing — under a screen-axis
1733
+ // front the whole cloud blows one way across the frame instead of radiating off the wave centre
1734
+ // in every direction (which puts dust back over the half that has not crumbled yet). Only the
1735
+ // screen axes have a direction to borrow; a uv front keeps radiating, which is what a ribbon
1736
+ // fraying along its own length should do.
1737
+ if (uDissolveAxis > 1.5) {
1738
+ vec3 sweep = (uDissolveAxis > 2.5 ? uUp : uRight) * (uDissolveReverse > 0.5 ? 1.0 : -1.0);
1739
+ outward = normalize(mix(outward, sweep, uDissolveDust) + vec3(1e-4));
1740
+ }
1741
+ }
1742
+ #endif
1027
1743
 
1028
1744
  #ifdef POINTER_FX
1029
1745
  // WELD (applied below, once the mote's own motion is known). The ribbon displaces its surface by
@@ -1092,6 +1808,7 @@ void main(){
1092
1808
  vAlpha = fade * mix(1.0, tw, clamp(uTwinkle, 0.0, 1.0));
1093
1809
  vColor = mix(uColor, uColor2, aRnd.w); // two-tone dust: per-particle blend of the two colours
1094
1810
  vDir = normalize(vec2(dot(outward, uRight), dot(outward, uUp)) + vec2(1e-4)); // outward, in screen space
1811
+ vSeed = aSeed;
1095
1812
  gl_Position = projectionMatrix * viewMatrix * vec4(p, 1.0);
1096
1813
  // Orthographic camera → point size is constant in device pixels (no perspective depth divide).
1097
1814
  float jitter = 1.0 + uSizeJitter * (aSeed - 0.5) * 2.0;
@@ -1100,10 +1817,11 @@ void main(){
1100
1817
  `;
1101
1818
  const particleFragmentShader = `
1102
1819
  precision highp float;
1103
- uniform float uShape; // 0 glitter · 1 soft · 2 ring · 3 star · 4 streak
1820
+ uniform float uShape; // 0 glitter · 1 soft · 2 ring · 3 star · 4 streak · 5 square
1104
1821
  varying float vAlpha;
1105
1822
  varying vec3 vColor;
1106
1823
  varying vec2 vDir;
1824
+ varying float vSeed; // this particle's seed — the square sprite cuts its own shard from it
1107
1825
  // User artwork (shape "sprite"), behind a define so a field without one compiles the exact same
1108
1826
  // program — and so the sampler only exists once a texture is actually bound to it. ONE texture is
1109
1827
  // shared by every particle in the field; see ParticleField.loadSprite for the rasterization.
@@ -1138,6 +1856,23 @@ void main(){
1138
1856
  float along = dot(pc, vDir);
1139
1857
  float perp = dot(pc, vec2(-vDir.y, vDir.x));
1140
1858
  a = smoothstep(0.5, 0.0, length(vec2(along * 0.42, perp * 2.2)));
1859
+ } else if (s == 5) { // square: a hard-edged chip of debris
1860
+ // A rectangle, not a disc: Chebyshev distance in place of Euclidean, screen-aligned because a
1861
+ // point sprite already is. But a field of IDENTICAL squares reads as grain rather than debris,
1862
+ // so each one cuts its own shard out of its quad — its own extent, proportion and quarter-turn,
1863
+ // from three hashes of the particle seed. The extent is SQUARED, which gives the heavy tail real
1864
+ // rubble has: mostly small chips with a few big slabs among them, rather than one uniform size.
1865
+ float h1 = fract(sin(vSeed * 127.1) * 43758.5453);
1866
+ float h2 = fract(sin(vSeed * 311.7) * 24634.6345);
1867
+ float h3 = fract(sin(vSeed * 74.7) * 39158.5453);
1868
+ float ext = mix(0.10, 0.47, h1 * h1);
1869
+ float asp = mix(0.38, 1.0, h2);
1870
+ vec2 q = h3 > 0.5 ? pc.yx : pc; // half the shards are bars the other way round
1871
+ float m = max(abs(q.x) / asp, abs(q.y));
1872
+ // Antialias over one pixel of the sprite quad, so a 3px chip has a clean edge and a 30px slab
1873
+ // is not blurred by a fixed ramp sized for the small ones.
1874
+ float w = max(fwidth(m), 1.0e-4);
1875
+ a = 1.0 - smoothstep(ext - w, ext + w, m);
1141
1876
  } else { // glitter (0): the soft round additive disc
1142
1877
  a = smoothstep(0.5, 0.0, d);
1143
1878
  }
@@ -1148,6 +1883,6 @@ void main(){
1148
1883
  }
1149
1884
  `;
1150
1885
  //#endregion
1151
- export { ditherFragmentShader, fragmentShader, halftoneCmykFragmentShader, halftoneFragmentShader, heatmapFragmentShader, innerLightFragmentShader, lineFragmentShader, paperTextureFragmentShader, particleFragmentShader, particleVertexShader, postFragmentShader, postVertexShader, vertexShader };
1886
+ export { ditherFragmentShader, fragmentShader, glassFragmentShader, glassLayerFragmentShader, halftoneCmykFragmentShader, halftoneFragmentShader, heatmapFragmentShader, innerLightFragmentShader, lineFragmentShader, paperTextureFragmentShader, particleFragmentShader, particleVertexShader, postFragmentShader, postVertexShader, vertexShader };
1152
1887
 
1153
1888
  //# sourceMappingURL=shaders.js.map