@wave3d/core 0.9.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.
- package/dist/config/model.d.ts +209 -6
- package/dist/config/model.js +85 -3
- package/dist/config/model.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/presets.js +295 -0
- package/dist/presets.js.map +1 -1
- package/dist/renderer/WaveGeometry.js +21 -0
- package/dist/renderer/WaveGeometry.js.map +1 -1
- package/dist/renderer/WaveRenderer.d.ts +62 -0
- package/dist/renderer/WaveRenderer.js +349 -10
- package/dist/renderer/WaveRenderer.js.map +1 -1
- package/dist/renderer/WaveRendererGPU.js +32 -2
- package/dist/renderer/WaveRendererGPU.js.map +1 -1
- package/dist/renderer/interaction.js +12 -0
- package/dist/renderer/interaction.js.map +1 -1
- package/dist/renderer/particleField.js +26 -2
- package/dist/renderer/particleField.js.map +1 -1
- package/dist/renderer/particleFieldGPU.js +6 -0
- package/dist/renderer/particleFieldGPU.js.map +1 -1
- package/dist/renderer/shaders.js +748 -13
- package/dist/renderer/shaders.js.map +1 -1
- package/dist/renderer/tsl/dissolve.js +56 -0
- package/dist/renderer/tsl/dissolve.js.map +1 -0
- package/dist/renderer/tsl/particleMaterial.js +46 -8
- package/dist/renderer/tsl/particleMaterial.js.map +1 -1
- package/dist/renderer/tsl/uniforms.js +40 -1
- package/dist/renderer/tsl/uniforms.js.map +1 -1
- package/dist/renderer/tsl/waveMaterial.js +275 -26
- package/dist/renderer/tsl/waveMaterial.js.map +1 -1
- package/dist/renderer/tsl/waveShape.js +31 -10
- package/dist/renderer/tsl/waveShape.js.map +1 -1
- package/dist/renderer/wavePath.js +189 -0
- package/dist/renderer/wavePath.js.map +1 -0
- package/dist/shell/createWave.d.ts +23 -3
- package/dist/shell/createWave.js +5 -4
- package/dist/shell/createWave.js.map +1 -1
- package/dist/shell/probe.d.ts +28 -0
- package/dist/shell/probe.js +58 -11
- package/dist/shell/probe.js.map +1 -1
- package/dist/standalone/wave3d.standalone.js +3401 -2108
- package/dist/standalone/wave3d.standalone.webgpu.js +7372 -5848
- package/dist/standalone.d.ts +2 -2
- package/dist/standalone.js +2 -2
- package/dist/studio/StudioWaveRenderer.d.ts +115 -8
- package/dist/studio/StudioWaveRenderer.js +588 -14
- package/dist/studio/StudioWaveRenderer.js.map +1 -1
- package/dist/studio/index.d.ts +2 -2
- package/dist/studio/index.js.map +1 -1
- package/dist/studio/randomize.js +0 -1
- package/dist/studio/randomize.js.map +1 -1
- package/package.json +1 -1
- package/skills/wave3d/SKILL.md +142 -5
package/dist/renderer/shaders.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
719
|
-
|
|
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
|
-
|
|
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;
|
|
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
|