@wave3d/core 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -258,6 +258,102 @@ WaveShape waveShape(vec3 position, vec2 uv, float t, vec2 loopOff){
258
258
  return s;
259
259
  }
260
260
  `;
261
+ const pointerFieldChunk = `
262
+ uniform vec2 uPointer; // smoothed pointer, NDC (-1..1)
263
+ uniform float uPointerActive; // presence ramp 0..1 × per-wave influence
264
+ uniform float uPointerRadius; // falloff radius in NDC-y units (config radius × 2)
265
+ uniform float uPointerAspect; // drawing-buffer dw/dh (circular screen falloff)
266
+ uniform float uPointerAgitate;
267
+ uniform float uPointerPush; // signed membrane dome at the cursor (+ repel / − attract)
268
+ uniform float uPointerWake; // drag-wake trough amplitude (behind the moving cursor)
269
+ uniform vec2 uPointerVel; // smoothed pointer velocity, NDC/s (drag-wake direction)
270
+ // Ribbon flow: stretch the falloff along the strip's length axis so the field reaches ALONG the
271
+ // ribbon rather than as a screen disc. 0 = the plain circular smoothstep (byte-identical when off).
272
+ uniform float uShapeFlow;
273
+ #ifdef POINTER_RIPPLES
274
+ uniform vec2 uRippleOrigin[4]; // NDC
275
+ uniform float uRippleAge[4]; // seconds since spawn (CPU-computed)
276
+ uniform float uRippleAmp[4]; // shared 0..1 decay envelope per slot (CPU-computed; 0 = slot free)
277
+ uniform float uPointerRipple; // THIS wave's ripple amplitude (scales the shared envelope)
278
+ const float RIPPLE_WAVE_SPEED = 0.85; // NDC/s the ring crest travels outward
279
+ const float RIPPLE_SIGMA = 0.14; // gaussian half-width of the travelling packet (NDC)
280
+ const float RIPPLE_FREQ = 11.0; // oscillation within the packet (one crest + faint troughs)
281
+ const float RIPPLE_MAX_R = 1.2; // reach where the crest has fully left the frame
282
+ #endif
283
+
284
+ // fall = screen falloff × presence (the wave's vPointerFall, which both fragment themes consume);
285
+ // disp = the signed displacement along the surface's own up-axis, which the CALLER applies (the wave
286
+ // in its local space, the dust through the wave's world matrix).
287
+ struct PointerHit { float fall; float disp; };
288
+
289
+ // Sample the field for ONE point. ndc is that point's screen position; mvp the clip transform of the
290
+ // space rotA/rotB/rotC and churnPos live in (the owning wave's local space); t / loopOff the caller's
291
+ // linear / orbit time — only the one selected by LOOP_MOTION is read.
292
+ PointerHit pointerField(vec2 ndc, mat4 mvp, mat4 rotA, mat4 rotB, mat4 rotC, vec3 churnPos,
293
+ float t, vec2 loopOff){
294
+ // Screen-space offset from the cursor (aspect-corrected → round in pixels). The DEFAULT metric.
295
+ vec2 dp = (ndc - uPointer) * vec2(uPointerAspect, 1.0);
296
+ // Ribbon flow: stretch the metric along the strip's own LENGTH axis so the field reaches ALONG the
297
+ // ribbon and stays tight across it — the "flows with the material" feel, per-vertex (so it follows
298
+ // the strip's curve) with no CPU surface pick. The length axis is local +X (uv.x runs with x)
299
+ // carried through the SAME twist as the surface. The camera is orthographic (affine, w=1), so the
300
+ // axis's screen image is the linear map of the DIRECTION (w=0): one mat·dir, no second
301
+ // point-projection and no perspective divide. (A true per-pixel uv would need GPU picking — the
302
+ // visible surface is shader-displaced, so a CPU raycast of the base geometry misses.)
303
+ if (uShapeFlow > 0.0) {
304
+ vec3 tangentLocal = (((vec4(1.0, 0.0, 0.0, 0.0) * rotA) * rotB) * rotC).xyz;
305
+ vec2 tang = (mvp * vec4(tangentLocal, 0.0)).xy * vec2(uPointerAspect, 1.0);
306
+ float tl = length(tang);
307
+ if (tl > 1.0e-6) {
308
+ tang /= tl;
309
+ vec2 nrm = vec2(-tang.y, tang.x);
310
+ dp = vec2(dot(dp, tang) / (1.0 + uShapeFlow * 2.5), dot(dp, nrm)); // up to 3.5× reach along length
311
+ }
312
+ }
313
+ float fall = smoothstep(uPointerRadius, 0.0, length(dp)) * uPointerActive;
314
+ // Agitation: a fast churn octave near the cursor (additive — never rewrites base noise t, which
315
+ // would force restructuring the shared path). Loop-safe under both time variants.
316
+ #ifdef LOOP_MOTION
317
+ float disp = uPointerAgitate * fall
318
+ * simplexNoise(vec2(churnPos.x * uDispFreqX * 3.0, churnPos.z * uDispFreqZ * 3.0) + loopOff * 4.0);
319
+ #else
320
+ float disp = uPointerAgitate * fall
321
+ * simplexNoise(vec2(churnPos.x * uDispFreqX * 3.0 + t * 4.0, churnPos.z * uDispFreqZ * 3.0));
322
+ #endif
323
+ // Membrane push/pull: a smooth dome (fall is the falloff) that swells toward you (+ repel) or dents
324
+ // away (− attract) at the cursor, riding along with the sprung field.
325
+ disp += uPointerPush * fall;
326
+ // Drag-wake: pull the surface just BEHIND the moving cursor into a trailing trough. dp points
327
+ // from cursor to vertex; "behind" is how far the vertex sits opposite the velocity (0 ahead → 1 a
328
+ // radius behind), gated by speed so it only forms while dragging and heals when the cursor stops.
329
+ vec2 velC = uPointerVel * vec2(uPointerAspect, 1.0);
330
+ float wakeSpeed = length(velC);
331
+ if (uPointerWake != 0.0 && wakeSpeed > 1.0e-4) {
332
+ float behind = clamp(dot(-dp, velC) / (wakeSpeed * uPointerRadius), 0.0, 1.0);
333
+ disp -= uPointerWake * fall * behind * smoothstep(0.05, 0.6, wakeSpeed);
334
+ }
335
+ #ifdef POINTER_RIPPLES
336
+ for (int i = 0; i < 4; i++) {
337
+ if (uRippleAmp[i] > 0.0) {
338
+ float rd = length((ndc - uRippleOrigin[i]) * vec2(uPointerAspect, 1.0));
339
+ // A wave PACKET whose crest travels outward at RIPPLE_WAVE_SPEED: a gaussian window centred on
340
+ // the moving front carrying a short oscillation (a raised ring with faint trailing troughs),
341
+ // so the energy radiates instead of throbbing at the click point. The shared uRippleAmp
342
+ // envelope fades the whole packet over its lifetime; reach fades it as the crest leaves frame.
343
+ float front = uRippleAge[i] * RIPPLE_WAVE_SPEED;
344
+ float band = rd - front;
345
+ float packet = exp(-band * band / (2.0 * RIPPLE_SIGMA * RIPPLE_SIGMA)) * cos(band * RIPPLE_FREQ);
346
+ float reach = 1.0 - smoothstep(RIPPLE_MAX_R * 0.7, RIPPLE_MAX_R, front);
347
+ disp += uPointerRipple * uRippleAmp[i] * packet * reach;
348
+ }
349
+ }
350
+ #endif
351
+ PointerHit hit;
352
+ hit.fall = fall;
353
+ hit.disp = disp;
354
+ return hit;
355
+ }
356
+ `;
261
357
  const vertexShader = `
262
358
  ${simplex2d}
263
359
 
@@ -291,32 +387,12 @@ varying vec3 vWorldPos;
291
387
  varying vec3 vViewDir;
292
388
  varying vec4 vClipPosition; // = gl_Position, for the wireframe theme's depth fade
293
389
 
294
- // Pointer field (optional, additive). ALL declarations here sit behind POINTER_FX so a wave with
295
- // no interaction config compiles the exact same program (JS-side uniform entries are always present
296
- // see makeUniforms but three only uploads uniforms the compiled program actually declares).
390
+ // Pointer field (optional, additive) the shared chunk, gated so a wave with no interaction config
391
+ // compiles the exact same program. The particle emitter interpolates the SAME chunk, so dust reacts
392
+ // through one implementation of the footprint / falloff / displacement.
297
393
  #ifdef POINTER_FX
298
- uniform vec2 uPointer; // smoothed pointer, NDC (-1..1)
299
- uniform float uPointerActive; // presence ramp 0..1 × per-wave influence
300
- uniform float uPointerRadius; // falloff radius in NDC-y units (config radius × 2)
301
- uniform float uPointerAspect; // drawing-buffer dw/dh (circular screen falloff)
302
- uniform float uPointerAgitate;
303
- uniform float uPointerPush; // signed membrane dome at the cursor (+ repel / − attract)
304
- uniform float uPointerWake; // drag-wake trough amplitude (behind the moving cursor)
305
- uniform vec2 uPointerVel; // smoothed pointer velocity, NDC/s (drag-wake direction)
306
- // Ribbon flow: stretch the falloff along the strip's length axis so the field reaches ALONG the
307
- // ribbon rather than as a screen disc. 0 = the plain circular smoothstep (byte-identical when off).
308
- uniform float uShapeFlow;
394
+ ${pointerFieldChunk}
309
395
  varying float vPointerFall; // falloff × presence — consumed by both fragment themes
310
- #ifdef POINTER_RIPPLES
311
- uniform vec2 uRippleOrigin[4]; // NDC
312
- uniform float uRippleAge[4]; // seconds since spawn (CPU-computed)
313
- uniform float uRippleAmp[4]; // shared 0..1 decay envelope per slot (CPU-computed; 0 = slot free)
314
- uniform float uPointerRipple; // THIS wave's ripple amplitude (scales the shared envelope)
315
- const float RIPPLE_WAVE_SPEED = 0.85; // NDC/s the ring crest travels outward
316
- const float RIPPLE_SIGMA = 0.14; // gaussian half-width of the travelling packet (NDC)
317
- const float RIPPLE_FREQ = 11.0; // oscillation within the packet (one crest + faint troughs)
318
- const float RIPPLE_MAX_R = 1.2; // reach where the crest has fully left the frame
319
- #endif
320
396
  #endif
321
397
 
322
398
  ${waveShapeChunk}
@@ -351,75 +427,20 @@ void main(){
351
427
  // Pointer field: displace along the wave's own (post-twist) up-axis, weighted by a screen-space
352
428
  // falloff around the smoothed cursor — a circle at uShapeFlow 0, stretched along the ribbon as it
353
429
  // rises. Everything here is ADDITIVE and fenced, so the shared path above/below is untouched and
354
- // byte-identical when POINTER_FX is off.
430
+ // byte-identical when POINTER_FX is off. The field itself lives in pointerFieldChunk, which the
431
+ // particle emitter also calls — so dust reacts through this exact footprint and falloff.
355
432
  // Shared clip-space transform, computed once and reused for the cursor metric and the ribbon
356
433
  // tangent (the compiler is not guaranteed to CSE the triple product otherwise). Associativity is
357
434
  // unchanged, so preClip is bit-for-bit what the plain P*V*M*v product produced.
358
435
  mat4 mvp = projectionMatrix * viewMatrix * modelMatrix;
359
436
  vec4 preClip = mvp * vec4(pos, 1.0);
360
- // Screen-space offset from the cursor (aspect-corrected → round in pixels). The DEFAULT metric.
361
- vec2 ndcHere = preClip.xy / max(preClip.w, 1.0e-6);
362
- vec2 dp = (ndcHere - uPointer) * vec2(uPointerAspect, 1.0);
363
- // Ribbon flow: stretch the metric along the strip's own LENGTH axis so the field reaches ALONG the
364
- // ribbon and stays tight across it — the "flows with the material" feel, per-vertex (so it follows
365
- // the strip's curve) with no CPU surface pick. The length axis is local +X (uv.x runs with x)
366
- // carried through the SAME twist as the surface. The camera is orthographic (affine, w=1), so the
367
- // axis's screen image is the linear map of the DIRECTION (w=0): one mat·dir, no second
368
- // point-projection and no perspective divide. (A true per-pixel uv would need GPU picking — the
369
- // visible surface is shader-displaced, so a CPU raycast of the base geometry misses.)
370
- if (uShapeFlow > 0.0) {
371
- vec3 tangentLocal = (((vec4(1.0, 0.0, 0.0, 0.0) * ws.rotA) * ws.rotB) * ws.rotC).xyz;
372
- vec2 tang = (mvp * vec4(tangentLocal, 0.0)).xy * vec2(uPointerAspect, 1.0);
373
- float tl = length(tang);
374
- if (tl > 1.0e-6) {
375
- tang /= tl;
376
- vec2 nrm = vec2(-tang.y, tang.x);
377
- dp = vec2(dot(dp, tang) / (1.0 + uShapeFlow * 2.5), dot(dp, nrm)); // up to 3.5× reach along length
378
- }
379
- }
380
- float fall = smoothstep(uPointerRadius, 0.0, length(dp));
381
- vPointerFall = fall * uPointerActive;
437
+ PointerHit hit = pointerField(preClip.xy / max(preClip.w, 1.0e-6), mvp,
438
+ ws.rotA, ws.rotB, ws.rotC, pos, t, loopOff);
439
+ vPointerFall = hit.fall;
382
440
  // Displacement axis = local +Y carried through the SAME three twist rotations as pos (row-vector
383
441
  // convention). Rotations are linear, so post-twist axis displacement equals pre-twist Y displacement.
384
442
  vec3 dispAxis = (((vec4(0.0, 1.0, 0.0, 0.0) * ws.rotA) * ws.rotB) * ws.rotC).xyz;
385
- // Agitation: a fast churn octave near the cursor (additive — never rewrites base noise t, which
386
- // would force restructuring the shared path). Loop-safe under both time variants.
387
- #ifdef LOOP_MOTION
388
- float disp = uPointerAgitate * vPointerFall
389
- * simplexNoise(vec2(pos.x * uDispFreqX * 3.0, pos.z * uDispFreqZ * 3.0) + loopOff * 4.0);
390
- #else
391
- float disp = uPointerAgitate * vPointerFall
392
- * simplexNoise(vec2(pos.x * uDispFreqX * 3.0 + t * 4.0, pos.z * uDispFreqZ * 3.0));
393
- #endif
394
- // Membrane push/pull: a smooth dome (vPointerFall is the falloff) that swells toward you (+ repel)
395
- // or dents away (− attract) at the cursor, riding along with the sprung field.
396
- disp += uPointerPush * vPointerFall;
397
- // Drag-wake: pull the surface just BEHIND the moving cursor into a trailing trough. dp points
398
- // from cursor to vertex; "behind" is how far the vertex sits opposite the velocity (0 ahead → 1 a
399
- // radius behind), gated by speed so it only forms while dragging and heals when the cursor stops.
400
- vec2 velC = uPointerVel * vec2(uPointerAspect, 1.0);
401
- float wakeSpeed = length(velC);
402
- if (uPointerWake != 0.0 && wakeSpeed > 1.0e-4) {
403
- float behind = clamp(dot(-dp, velC) / (wakeSpeed * uPointerRadius), 0.0, 1.0);
404
- disp -= uPointerWake * vPointerFall * behind * smoothstep(0.05, 0.6, wakeSpeed);
405
- }
406
- #ifdef POINTER_RIPPLES
407
- for (int i = 0; i < 4; i++) {
408
- if (uRippleAmp[i] > 0.0) {
409
- float rd = length((preClip.xy / max(preClip.w, 1.0e-6) - uRippleOrigin[i]) * vec2(uPointerAspect, 1.0));
410
- // A wave PACKET whose crest travels outward at RIPPLE_WAVE_SPEED: a gaussian window centred on
411
- // the moving front carrying a short oscillation (a raised ring with faint trailing troughs),
412
- // so the energy radiates instead of throbbing at the click point. The shared uRippleAmp
413
- // envelope fades the whole packet over its lifetime; reach fades it as the crest leaves frame.
414
- float front = uRippleAge[i] * RIPPLE_WAVE_SPEED;
415
- float band = rd - front;
416
- float packet = exp(-band * band / (2.0 * RIPPLE_SIGMA * RIPPLE_SIGMA)) * cos(band * RIPPLE_FREQ);
417
- float reach = 1.0 - smoothstep(RIPPLE_MAX_R * 0.7, RIPPLE_MAX_R, front);
418
- disp += uPointerRipple * uRippleAmp[i] * packet * reach;
419
- }
420
- }
421
- #endif
422
- pos += dispAxis * disp;
443
+ pos += dispAxis * hit.disp;
423
444
  #endif
424
445
 
425
446
  // The scale / rotation / position transform lives on the mesh (modelMatrix), so the
@@ -964,6 +985,14 @@ uniform mat4 uShedModel; // the wave's matrixWorld (deformed LOCAL
964
985
  uniform float uShedSpeed, uShedSeed;
965
986
  ${waveShapeChunk}
966
987
 
988
+ // The cursor. Same chunk the ribbon uses, mirrored onto this material in ParticleField.configure(),
989
+ // and behind the same POINTER_FX gate — a wave with no hover field compiles the point program it
990
+ // always did. uPartShove is the one particle-only knob (see the two samples in main).
991
+ #ifdef POINTER_FX
992
+ ${pointerFieldChunk}
993
+ uniform float uPartShove; // how hard the cursor shoves dust that has already drifted free (0 = off)
994
+ #endif
995
+
967
996
  varying float vAlpha;
968
997
  varying vec3 vColor;
969
998
  varying vec2 vDir; // screen-space motion direction (for the streak sprite)
@@ -992,17 +1021,43 @@ void main(){
992
1021
  // Approximate the base hairpin point for this uv (length from uv.y; width centre), then deform it
993
1022
  // exactly as the wave does. Good enough for dust — the fan / displacement dominate.
994
1023
  vec3 base = vec3((aUv.y - 0.5) * 400.0, 0.0, ${(-8).toFixed(1)});
995
- vec3 origin = (uShedModel * vec4(waveShape(base, aUv, ts, loopOff).pos, 1.0)).xyz;
1024
+ WaveShape ws = waveShape(base, aUv, ts, loopOff);
1025
+ vec3 origin = (uShedModel * vec4(ws.pos, 1.0)).xyz;
996
1026
  vec3 outward = normalize(origin - uCenter + vec3(1e-4));
1027
+
1028
+ #ifdef POINTER_FX
1029
+ // WELD (applied below, once the mote's own motion is known). The ribbon displaces its surface by
1030
+ // pointerField() along its own post-twist up-axis; a mote sitting ON that surface has to take the
1031
+ // same ride, or the cursor's dome lifts the silk out from under its own glitter. Sampled at the
1032
+ // SPAWN point and carried through the local→world matrix exactly as the ribbon's own
1033
+ // pos += dispAxis * disp is, so the two land on the same place. Sampling at the spawn point also
1034
+ // leaves outward derived from the UNDISPLACED origin, so a poke never bends the drift direction.
1035
+ mat4 pMvp = projectionMatrix * viewMatrix * uShedModel;
1036
+ vec4 originClip = pMvp * vec4(ws.pos, 1.0);
1037
+ vec3 dispAxis = mat3(uShedModel) * (((vec4(0.0, 1.0, 0.0, 0.0) * ws.rotA) * ws.rotB) * ws.rotC).xyz;
1038
+ PointerHit weld = pointerField(originClip.xy / max(originClip.w, 1.0e-6), pMvp,
1039
+ ws.rotA, ws.rotB, ws.rotC, ws.pos, ts, loopOff);
1040
+ #endif
1041
+
997
1042
  vec3 p = origin + outward * age * uDrift + (aRnd.xyz - 0.5) * age * uDrift * 0.35;
998
1043
 
999
1044
  // Motion styles, each 0 = off, all riding age so they stay loop-safe (the age wrap is hidden by
1000
1045
  // fade→0 at birth/death). rise = screen-vertical buoyancy (embers up / snow down); swirl = orbit
1001
1046
  // around the wave centre in the screen plane; wander = curl-noise turbulence (fireflies / motes).
1002
1047
  p += uUp * age * uRise;
1048
+ // How far this mote travels away from its birth patch over a WHOLE life — the straight-line terms
1049
+ // plus, under swirl, the arc it sweeps at its own orbit radius. Only the pointer weld reads it
1050
+ // (0 for dust that merely clings to the surface, which is exactly the case age would get wrong),
1051
+ // so it is fenced like everything else the cursor drives.
1052
+ #ifdef POINTER_FX
1053
+ float span = abs(uDrift) * 1.35 + abs(uRise) + uWander;
1054
+ #endif
1003
1055
  if (uSwirl != 0.0) {
1004
1056
  vec3 nrm = cross(uRight, uUp);
1005
1057
  vec3 rel = p - uCenter;
1058
+ #ifdef POINTER_FX
1059
+ span += abs(uSwirl) * TAU * length(rel);
1060
+ #endif
1006
1061
  float rx = dot(rel, uRight), ry = dot(rel, uUp), rz = dot(rel, nrm);
1007
1062
  float a = age * uSwirl * TAU;
1008
1063
  float ca = cos(a), sa = sin(a);
@@ -1014,6 +1069,25 @@ void main(){
1014
1069
  p += (uRight * wan.x + uUp * wan.y) * uWander;
1015
1070
  }
1016
1071
 
1072
+ #ifdef POINTER_FX
1073
+ // How attached to its birth patch this mote still is: 1 while it sits on the surface, 0 once it
1074
+ // has travelled a full life's worth away. Measured from the DISTANCE it actually moved rather than
1075
+ // from age, because dust with no drift / rise / swirl / wander never leaves the surface at all —
1076
+ // an age fade would quietly stop that dust from following the ribbon halfway through its life.
1077
+ float attach = span > 1.0e-4 ? 1.0 - clamp(length(p - origin) / span, 0.0, 1.0) : 1.0;
1078
+ p += dispAxis * (weld.disp * attach);
1079
+ // SHOVE: the exact complement. The same field sampled at the mote's OWN screen position, so the
1080
+ // cursor also pushes dust that has already left the surface — and a click ripple visibly blows
1081
+ // through the cloud instead of stopping dead at the ribbon. Uniform branch (warp-coherent), so
1082
+ // uPartShove 0 costs nothing.
1083
+ if (uPartShove != 0.0) {
1084
+ vec4 pClip = projectionMatrix * viewMatrix * vec4(p, 1.0);
1085
+ PointerHit shove = pointerField(pClip.xy / max(pClip.w, 1.0e-6), pMvp,
1086
+ ws.rotA, ws.rotB, ws.rotC, ws.pos, ts, loopOff);
1087
+ p += dispAxis * (shove.disp * (1.0 - attach) * uPartShove);
1088
+ }
1089
+ #endif
1090
+
1017
1091
  float tw = 0.5 + 0.5 * sin((age * 9.0 + aSeed) * TAU); // loop-safe flicker (rides age)
1018
1092
  vAlpha = fade * mix(1.0, tw, clamp(uTwinkle, 0.0, 1.0));
1019
1093
  vColor = mix(uColor, uColor2, aRnd.w); // two-tone dust: per-particle blend of the two colours
@@ -1030,7 +1104,24 @@ uniform float uShape; // 0 glitter · 1 soft · 2 ring · 3 star · 4 streak
1030
1104
  varying float vAlpha;
1031
1105
  varying vec3 vColor;
1032
1106
  varying vec2 vDir;
1107
+ // User artwork (shape "sprite"), behind a define so a field without one compiles the exact same
1108
+ // program — and so the sampler only exists once a texture is actually bound to it. ONE texture is
1109
+ // shared by every particle in the field; see ParticleField.loadSprite for the rasterization.
1110
+ #ifdef PARTICLE_SPRITE
1111
+ uniform sampler2D uSprite;
1112
+ #endif
1033
1113
  void main(){
1114
+ #ifdef PARTICLE_SPRITE
1115
+ // gl_PointCoord's origin is the sprite's TOP-left with y running DOWN, so it has to be flipped or
1116
+ // every sprite draws upside down. The procedural shapes below never needed this — they are all
1117
+ // symmetric about y, which is exactly why the bug would have gone unnoticed.
1118
+ vec4 tex = texture2D(uSprite, vec2(gl_PointCoord.x, 1.0 - gl_PointCoord.y));
1119
+ float a = tex.a * vAlpha;
1120
+ if (a <= 0.0) discard;
1121
+ // Tinted by the dust colour so color / color2 keep working: white artwork takes the tint
1122
+ // exactly, coloured artwork multiplies it.
1123
+ gl_FragColor = vec4(vColor * tex.rgb, a);
1124
+ #else
1034
1125
  vec2 pc = gl_PointCoord - 0.5;
1035
1126
  float d = length(pc);
1036
1127
  int s = int(uShape + 0.5);
@@ -1053,6 +1144,7 @@ void main(){
1053
1144
  a *= vAlpha;
1054
1145
  if (a <= 0.0) discard;
1055
1146
  gl_FragColor = vec4(vColor, a); // AdditiveBlending (src = SrcAlpha) → adds vColor·a
1147
+ #endif
1056
1148
  }
1057
1149
  `;
1058
1150
  //#endregion