@firecms/neat 0.4.0 → 0.5.1

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.
@@ -4,7 +4,7 @@ const PLANE_WIDTH = 50;
4
4
  const PLANE_HEIGHT = 80;
5
5
 
6
6
  const WIREFRAME = true;
7
- const COLORS_COUNT = 5;
7
+ const COLORS_COUNT = 6;
8
8
 
9
9
  const clock = new THREE.Clock();
10
10
 
@@ -18,6 +18,16 @@ type SceneState = {
18
18
  resolution: number
19
19
  }
20
20
 
21
+ // Interface for the Uniforms to avoid @ts-ignore and improve access speed
22
+ interface NeatUniforms {
23
+ [key: string]: THREE.IUniform;
24
+ u_time: { value: number };
25
+ u_resolution: { value: THREE.Vector2 };
26
+ u_color_pressure: { value: THREE.Vector2 };
27
+ u_colors: { value: { is_active: number; color: THREE.Color; influence: number }[] };
28
+ u_mouse_texture: { value: THREE.Texture | null };
29
+ }
30
+
21
31
  export type NeatConfig = {
22
32
  resolution?: number;
23
33
  speed?: number;
@@ -40,6 +50,37 @@ export type NeatConfig = {
40
50
  backgroundColor?: string;
41
51
  backgroundAlpha?: number;
42
52
  yOffset?: number;
53
+ yOffsetWaveMultiplier?: number;
54
+ yOffsetColorMultiplier?: number;
55
+ yOffsetFlowMultiplier?: number;
56
+ // Flow field parameters
57
+ flowDistortionA?: number;
58
+ flowDistortionB?: number;
59
+ flowScale?: number;
60
+ flowEase?: number;
61
+ flowEnabled?: boolean;
62
+ // Mouse interaction
63
+ /** Strength of mouse-driven distortion */
64
+ mouseDistortionStrength?: number;
65
+ /** Radius / area of mouse-driven distortion in UV space (0–1-ish) */
66
+ mouseDistortionRadius?: number;
67
+ /** How quickly mouse trails decay/fade (0.9=slow/wobbly, 0.99=fast/sharp) */
68
+ mouseDecayRate?: number;
69
+ mouseDarken?: number;
70
+ // Texture generation
71
+ enableProceduralTexture?: boolean;
72
+ textureVoidLikelihood?: number;
73
+ textureVoidWidthMin?: number;
74
+ textureVoidWidthMax?: number;
75
+ textureBandDensity?: number;
76
+ textureColorBlending?: number;
77
+ textureSeed?: number;
78
+ textureEase?: number;
79
+ proceduralBackgroundColor?: string;
80
+ textureShapeTriangles?: number;
81
+ textureShapeCircles?: number;
82
+ textureShapeBars?: number;
83
+ textureShapeSquiggles?: number;
43
84
  };
44
85
 
45
86
  export type NeatColor = {
@@ -86,11 +127,67 @@ export class NeatGradient implements NeatController {
86
127
  private _backgroundColor: string = "#FFFFFF";
87
128
  private _backgroundAlpha: number = 1.0;
88
129
 
130
+ // Flow field properties
131
+ private _flowDistortionA: number = 0;
132
+ private _flowDistortionB: number = 0;
133
+ private _flowScale: number = 1.0;
134
+ private _flowEase: number = 0.0;
135
+ private _flowEnabled: boolean = true;
136
+
137
+ // Mouse interaction properties
138
+ private _mouseDistortionStrength: number = 0.0;
139
+ private _mouseDistortionRadius: number = 0.25;
140
+ private _mouseDecayRate: number = 0.96;
141
+ private _mouseDarken: number = 0.0;
142
+ private _mouse: THREE.Vector2 = new THREE.Vector2(-1000, -1000);
143
+ private _mouseFBO: THREE.WebGLRenderTarget | null = null;
144
+ private _sceneMouse: THREE.Scene | null = null;
145
+ private _cameraMouse: THREE.OrthographicCamera | null = null;
146
+ private _mouseObjects: Array<{ mesh: THREE.Mesh, active: boolean }> = [];
147
+ private _currentBrush: number = 0;
148
+ private _mouseBrushBaseScale: number = 1;
149
+
150
+ // Texture generation properties
151
+ private _enableProceduralTexture: boolean = false;
152
+ private _textureVoidLikelihood: number = 0.45;
153
+ private _textureVoidWidthMin: number = 200;
154
+ private _textureVoidWidthMax: number = 486;
155
+ private _textureBandDensity: number = 2.15;
156
+ private _textureColorBlending: number = 0.01;
157
+ private _textureSeed: number = 333;
158
+ private _textureEase: number = 0.5;
159
+ private _proceduralTexture: THREE.Texture | null = null;
160
+ private _proceduralBackgroundColor: string = "#000000";
161
+
162
+ private _textureShapeTriangles: number = 20;
163
+ private _textureShapeCircles: number = 15;
164
+ private _textureShapeBars: number = 15;
165
+ private _textureShapeSquiggles: number = 10;
166
+
89
167
  private requestRef: number = -1;
90
168
  private sizeObserver: ResizeObserver;
91
169
  private sceneState: SceneState;
92
170
 
171
+ // Optimization: Cache uniforms to avoid lookups and object creation in render loop
172
+ private _cachedUniforms: NeatUniforms | null = null;
173
+ private _linkElement: HTMLAnchorElement | null = null;
174
+
93
175
  private _yOffset: number = 0;
176
+ private _yOffsetWaveMultiplier: number = 0.004;
177
+ private _yOffsetColorMultiplier: number = 0.004;
178
+ private _yOffsetFlowMultiplier: number = 0.004;
179
+
180
+ // For saving/restoring clear color
181
+ private _tempClearColor = new THREE.Color();
182
+
183
+ // Performance optimizations
184
+ private _resizeTimeoutId: number | null = null;
185
+ private _textureNeedsUpdate: boolean = false;
186
+ private _lastColorUpdate: number = 0;
187
+ private _linkCheckCounter: number = 0;
188
+ private _mouseUpdateScheduled: boolean = false;
189
+ private _pendingMousePosition: { x: number; y: number } | null = null;
190
+ private _colorsChanged: boolean = true; // Track if colors need update
94
191
 
95
192
  constructor(config: NeatConfig & { ref: HTMLCanvasElement, resolution?: number, seed?: number }) {
96
193
 
@@ -117,7 +214,35 @@ export class NeatGradient implements NeatController {
117
214
  backgroundAlpha = 1.0,
118
215
  resolution = 1,
119
216
  seed,
120
- yOffset = 0
217
+ yOffset = 0,
218
+ yOffsetWaveMultiplier = 4,
219
+ yOffsetColorMultiplier = 4,
220
+ yOffsetFlowMultiplier = 4,
221
+ // Flow field parameters
222
+ flowDistortionA = 0,
223
+ flowDistortionB = 0,
224
+ flowScale = 1.0,
225
+ flowEase = 0.0,
226
+ flowEnabled = true,
227
+ // Mouse interaction
228
+ mouseDistortionStrength = 0.0,
229
+ mouseDistortionRadius = 0.25,
230
+ mouseDecayRate = 0.96,
231
+ mouseDarken = 0.0,
232
+ // Texture generation
233
+ enableProceduralTexture = false,
234
+ textureVoidLikelihood = 0.45,
235
+ textureVoidWidthMin = 200,
236
+ textureVoidWidthMax = 486,
237
+ textureBandDensity = 2.15,
238
+ textureColorBlending = 0.01,
239
+ textureSeed = 333,
240
+ textureEase = 0.5,
241
+ proceduralBackgroundColor = "#000000",
242
+ textureShapeTriangles = 20,
243
+ textureShapeCircles = 15,
244
+ textureShapeBars = 15,
245
+ textureShapeSquiggles = 10,
121
246
  } = config;
122
247
 
123
248
 
@@ -147,84 +272,183 @@ export class NeatGradient implements NeatController {
147
272
  this.backgroundColor = backgroundColor;
148
273
  this.backgroundAlpha = backgroundAlpha;
149
274
  this.yOffset = yOffset;
150
-
275
+ this.yOffsetWaveMultiplier = yOffsetWaveMultiplier;
276
+ this.yOffsetColorMultiplier = yOffsetColorMultiplier;
277
+ this.yOffsetFlowMultiplier = yOffsetFlowMultiplier;
278
+
279
+ // Flow field
280
+ this.flowDistortionA = flowDistortionA;
281
+ this.flowDistortionB = flowDistortionB;
282
+ this.flowScale = flowScale;
283
+ this.flowEase = flowEase;
284
+ this.flowEnabled = flowEnabled;
285
+
286
+ // Mouse interaction
287
+ this.mouseDistortionStrength = mouseDistortionStrength;
288
+ this.mouseDistortionRadius = mouseDistortionRadius;
289
+ this.mouseDecayRate = mouseDecayRate;
290
+ this.mouseDarken = mouseDarken;
291
+
292
+ // Texture generation
293
+ this.enableProceduralTexture = enableProceduralTexture;
294
+ this.textureVoidLikelihood = textureVoidLikelihood;
295
+ this.textureVoidWidthMin = textureVoidWidthMin;
296
+ this.textureVoidWidthMax = textureVoidWidthMax;
297
+ this.textureBandDensity = textureBandDensity;
298
+ this.textureColorBlending = textureColorBlending;
299
+ this.textureSeed = textureSeed;
300
+ this.textureEase = textureEase;
301
+ this._proceduralBackgroundColor = proceduralBackgroundColor;
302
+
303
+ this._textureShapeTriangles = textureShapeTriangles;
304
+ this._textureShapeCircles = textureShapeCircles;
305
+ this._textureShapeBars = textureShapeBars;
306
+ this._textureShapeSquiggles = textureShapeSquiggles;
307
+
308
+ // FIX 1: Setup mouse resources BEFORE building the material/scene
309
+ // This ensures u_mouse_texture isn't null during material compilation
310
+ this._setupMouseInteraction();
151
311
  this.sceneState = this._initScene(resolution);
152
312
 
153
313
  let tick = seed !== undefined ? seed : getElapsedSecondsInLastHour();
314
+
154
315
  const render = () => {
155
316
 
156
- const { renderer, camera, scene, meshes } = this.sceneState;
157
- if (Math.floor(tick * 10) % 5 === 0) {
158
- addNeatLink(ref);
317
+ const { renderer, camera, scene } = this.sceneState;
318
+
319
+ // Optimization: check if cached link is still valid in DOM less frequently
320
+ this._linkCheckCounter++;
321
+ if (this._linkCheckCounter >= 300) { // Check every ~5 seconds at 60fps
322
+ this._linkCheckCounter = 0;
323
+ if (!this._linkElement || !document.contains(this._linkElement)) {
324
+ this._linkElement = addNeatLink(ref);
325
+ }
159
326
  }
160
327
 
161
- renderer.setClearColor(this._backgroundColor, this._backgroundAlpha);
162
- meshes.forEach((mesh) => {
163
-
164
- const width = this._ref.width,
165
- height = this._ref.height;
166
-
167
- const colors = [
168
- ...this._colors.map(color => {
169
- let threeColor = new THREE.Color();
170
- threeColor.setStyle(color.color, "");
171
- return ({
172
- is_active: color.enabled,
173
- color: threeColor,
174
- influence: color.influence
175
- });
176
- }),
177
- ...Array.from({ length: COLORS_COUNT - this._colors.length }).map(() => ({
178
- is_active: false,
179
- color: new THREE.Color(0x000000)
180
- }))
181
- ];
328
+ // Update Uniforms efficiently without creating new objects
329
+ if (this._cachedUniforms) {
330
+ const u = this._cachedUniforms;
182
331
 
183
332
  tick += clock.getDelta() * this._speed;
184
- // @ts-ignore
185
- mesh.material.uniforms.u_time.value = tick;
186
- // @ts-ignore
187
- mesh.material.uniforms.u_resolution = { value: new THREE.Vector2(width, height) };
188
- // @ts-ignore
189
- mesh.material.uniforms.u_color_pressure = { value: new THREE.Vector2(this._horizontalPressure, this._verticalPressure) };
190
- // @ts-ignore
191
- mesh.material.uniforms.u_wave_frequency_x = { value: this._waveFrequencyX };
192
- // @ts-ignore
193
- mesh.material.uniforms.u_wave_frequency_y = { value: this._waveFrequencyY };
194
- // @ts-ignore
195
- mesh.material.uniforms.u_wave_amplitude = { value: this._waveAmplitude };
196
- // @ts-ignore
197
- mesh.material.uniforms.u_plane_width = { value: PLANE_WIDTH };
198
- // @ts-ignore
199
- mesh.material.uniforms.u_plane_height = { value: PLANE_HEIGHT };
200
- // @ts-ignore
201
- mesh.material.uniforms.u_color_blending = { value: this._colorBlending };
202
- // @ts-ignore
203
- mesh.material.uniforms.u_colors = { value: colors };
204
- // @ts-ignore
205
- mesh.material.uniforms.u_colors_count = { value: COLORS_COUNT };
206
- // @ts-ignore
207
- mesh.material.uniforms.u_shadows = { value: this._shadows };
208
- // @ts-ignore
209
- mesh.material.uniforms.u_highlights = { value: this._highlights };
210
- // @ts-ignore
211
- mesh.material.uniforms.u_saturation = { value: this._saturation };
212
- // @ts-ignore
213
- mesh.material.uniforms.u_brightness = { value: this._brightness };
214
- // @ts-ignore
215
- mesh.material.uniforms.u_grain_intensity = { value: this._grainIntensity };
216
- // @ts-ignore
217
- mesh.material.uniforms.u_grain_sparsity = { value: this._grainSparsity };
218
- // @ts-ignore
219
- mesh.material.uniforms.u_grain_speed = { value: this._grainSpeed };
220
- // @ts-ignore
221
- mesh.material.uniforms.u_grain_scale = { value: this._grainScale };
222
- // @ts-ignore
223
- mesh.material.uniforms.u_y_offset = { value: this._yOffset };
224
- // @ts-ignore
225
- mesh.material.wireframe = this._wireframe;
226
- });
227
333
 
334
+ u.u_time.value = tick;
335
+ u.u_resolution.value.set(this._ref.width, this._ref.height);
336
+ u.u_color_pressure.value.set(this._horizontalPressure, this._verticalPressure);
337
+
338
+ // Directly assign simple values
339
+ u.u_wave_frequency_x.value = this._waveFrequencyX;
340
+ u.u_wave_frequency_y.value = this._waveFrequencyY;
341
+ u.u_wave_amplitude.value = this._waveAmplitude;
342
+ u.u_color_blending.value = this._colorBlending;
343
+ u.u_shadows.value = this._shadows;
344
+ u.u_highlights.value = this._highlights;
345
+ u.u_saturation.value = this._saturation;
346
+ u.u_brightness.value = this._brightness;
347
+ u.u_grain_intensity.value = this._grainIntensity;
348
+ u.u_grain_sparsity.value = this._grainSparsity;
349
+ u.u_grain_speed.value = this._grainSpeed;
350
+ u.u_grain_scale.value = this._grainScale;
351
+ u.u_y_offset.value = this._yOffset;
352
+ u.u_y_offset_wave_multiplier.value = this._yOffsetWaveMultiplier;
353
+ u.u_y_offset_color_multiplier.value = this._yOffsetColorMultiplier;
354
+ u.u_y_offset_flow_multiplier.value = this._yOffsetFlowMultiplier;
355
+ u.u_flow_distortion_a.value = this._flowDistortionA;
356
+ u.u_flow_distortion_b.value = this._flowDistortionB;
357
+ u.u_flow_scale.value = this._flowScale;
358
+ u.u_flow_ease.value = this._flowEase;
359
+ u.u_flow_enabled.value = this._flowEnabled ? 1.0 : 0.0;
360
+ u.u_mouse_distortion_strength.value = this._mouseDistortionStrength;
361
+ u.u_mouse_distortion_radius.value = this._mouseDistortionRadius;
362
+ u.u_mouse_darken.value = this._mouseDarken;
363
+ u.u_enable_procedural_texture.value = this._enableProceduralTexture ? 1.0 : 0.0;
364
+
365
+ // Only regenerate procedural texture when needed
366
+ if (this._textureNeedsUpdate && this._enableProceduralTexture) {
367
+ if (this._proceduralTexture) {
368
+ this._proceduralTexture.dispose();
369
+ }
370
+ this._proceduralTexture = this._createProceduralTexture();
371
+ this._textureNeedsUpdate = false;
372
+ }
373
+
374
+ u.u_procedural_texture.value = this._proceduralTexture;
375
+ u.u_texture_ease.value = this._textureEase;
376
+
377
+ // Wireframe is a material property and must update every frame to avoid artifacts
378
+ // @ts-ignore - access material safely
379
+ this.sceneState.meshes[0].material.wireframe = this._wireframe;
380
+
381
+ // Optimized Color Update: Update immediately on change, or throttle to 10 times per second
382
+ const now = Date.now();
383
+ const shouldUpdate = this._colorsChanged || (now - this._lastColorUpdate > 100);
384
+
385
+ if (shouldUpdate) {
386
+ this._lastColorUpdate = now;
387
+ this._colorsChanged = false;
388
+
389
+ const shaderColors = u.u_colors.value;
390
+ for(let i = 0; i < COLORS_COUNT; i++) {
391
+ if (i < this._colors.length) {
392
+ const c = this._colors[i];
393
+ shaderColors[i].is_active = c.enabled ? 1.0 : 0.0;
394
+ shaderColors[i].color.setStyle(c.color, "");
395
+ shaderColors[i].influence = c.influence || 0;
396
+ } else {
397
+ shaderColors[i].is_active = 0.0;
398
+ }
399
+ }
400
+
401
+ u.u_colors_count.value = COLORS_COUNT;
402
+ }
403
+ }
404
+
405
+ // Render mouse interaction to FBO - optimize by only rendering when needed
406
+ if (this._mouseFBO && this._sceneMouse && this._cameraMouse && this._mouseDistortionStrength > 0) {
407
+ let hasActiveBrushes = false;
408
+
409
+ // Update mouse objects - decay rate controls how fast trails fade
410
+ for(let i = 0; i < this._mouseObjects.length; i++) {
411
+ const obj = this._mouseObjects[i];
412
+ if (obj.mesh.visible) {
413
+ hasActiveBrushes = true;
414
+ obj.mesh.rotation.z += 0.01;
415
+ if (obj.mesh.material instanceof THREE.MeshBasicMaterial) {
416
+ // Decay only affects opacity
417
+ obj.mesh.material.opacity *= this._mouseDecayRate;
418
+
419
+ if (obj.mesh.material.opacity < 0.01) {
420
+ obj.mesh.visible = false;
421
+ }
422
+ }
423
+ }
424
+ }
425
+
426
+ // Only render FBO if there are active brushes
427
+ if (hasActiveBrushes) {
428
+ // Store current clear color (likely the main background color)
429
+ renderer.getClearColor(this._tempClearColor);
430
+ const oldClearAlpha = renderer.getClearAlpha();
431
+
432
+ // Set clear color to Black/Transparent for the FBO.
433
+ renderer.setClearColor(0x000000, 0.0);
434
+
435
+ renderer.setRenderTarget(this._mouseFBO);
436
+ renderer.clear();
437
+ renderer.render(this._sceneMouse, this._cameraMouse);
438
+ renderer.setRenderTarget(null);
439
+
440
+ // Restore main background color for the actual scene render
441
+ renderer.setClearColor(this._tempClearColor, oldClearAlpha);
442
+
443
+ // Update mouse texture uniform
444
+ if (this._cachedUniforms) {
445
+ this._cachedUniforms.u_mouse_texture.value = this._mouseFBO.texture;
446
+ }
447
+ }
448
+ }
449
+
450
+ // Ensure we set the clear color for the main scene explicitly before rendering
451
+ renderer.setClearColor(this._backgroundColor, this._backgroundAlpha);
228
452
  renderer.render(scene, camera);
229
453
  this.requestRef = requestAnimationFrame(render);
230
454
  };
@@ -238,10 +462,30 @@ export class NeatGradient implements NeatController {
238
462
 
239
463
  this.sceneState.renderer.setSize(width, height, false);
240
464
  updateCamera(this.sceneState.camera, width, height);
465
+
466
+ // FIX 3: Update Mouse FBO and Camera on resize
467
+ // If we don't do this, mouse coordinates map incorrectly after a resize
468
+ if (this._mouseFBO && this._cameraMouse) {
469
+ const fSize = height / 2;
470
+ const aspect = width / height;
471
+ this._mouseFBO.setSize(width / 2, height / 2);
472
+ this._cameraMouse.left = -fSize * aspect;
473
+ this._cameraMouse.right = fSize * aspect;
474
+ this._cameraMouse.top = fSize;
475
+ this._cameraMouse.bottom = -fSize;
476
+ this._cameraMouse.updateProjectionMatrix();
477
+ }
241
478
  };
242
479
 
243
- this.sizeObserver = new ResizeObserver(entries => {
244
- setSize();
480
+ // Debounce resize to prevent excessive operations
481
+ this.sizeObserver = new ResizeObserver(() => {
482
+ if (this._resizeTimeoutId !== null) {
483
+ clearTimeout(this._resizeTimeoutId);
484
+ }
485
+ this._resizeTimeoutId = window.setTimeout(() => {
486
+ setSize();
487
+ this._resizeTimeoutId = null;
488
+ }, 100); // Wait 100ms after last resize event
245
489
  });
246
490
 
247
491
  this.sizeObserver.observe(ref);
@@ -254,6 +498,24 @@ export class NeatGradient implements NeatController {
254
498
  if (this) {
255
499
  cancelAnimationFrame(this.requestRef);
256
500
  this.sizeObserver.disconnect();
501
+
502
+ // Clear resize timeout
503
+ if (this._resizeTimeoutId !== null) {
504
+ clearTimeout(this._resizeTimeoutId);
505
+ this._resizeTimeoutId = null;
506
+ }
507
+
508
+ // Cleanup WebGL resources
509
+ if (this.sceneState) {
510
+ this.sceneState.renderer.dispose();
511
+ this.sceneState.meshes.forEach(m => {
512
+ m.geometry.dispose();
513
+ if(Array.isArray(m.material)) m.material.forEach(mat => mat.dispose());
514
+ else m.material.dispose();
515
+ });
516
+ }
517
+ if (this._mouseFBO) this._mouseFBO.dispose();
518
+ if (this._proceduralTexture) this._proceduralTexture.dispose();
257
519
  }
258
520
  }
259
521
 
@@ -290,6 +552,7 @@ export class NeatGradient implements NeatController {
290
552
 
291
553
  set colors(colors: NeatColor[]) {
292
554
  this._colors = colors;
555
+ this._colorsChanged = true; // Flag for immediate update
293
556
  }
294
557
 
295
558
  set highlights(highlights: number) {
@@ -348,11 +611,178 @@ export class NeatGradient implements NeatController {
348
611
  this._yOffset = yOffset;
349
612
  }
350
613
 
614
+ get yOffsetWaveMultiplier(): number {
615
+ return this._yOffsetWaveMultiplier * 1000;
616
+ }
617
+
618
+ set yOffsetWaveMultiplier(value: number) {
619
+ this._yOffsetWaveMultiplier = value / 1000;
620
+ }
621
+
622
+ get yOffsetColorMultiplier(): number {
623
+ return this._yOffsetColorMultiplier * 1000;
624
+ }
625
+
626
+ set yOffsetColorMultiplier(value: number) {
627
+ this._yOffsetColorMultiplier = value / 1000;
628
+ }
629
+
630
+ get yOffsetFlowMultiplier(): number {
631
+ return this._yOffsetFlowMultiplier * 1000;
632
+ }
633
+
634
+ set yOffsetFlowMultiplier(value: number) {
635
+ this._yOffsetFlowMultiplier = value / 1000;
636
+ }
637
+
638
+ set flowDistortionA(value: number) {
639
+ this._flowDistortionA = value;
640
+ }
641
+
642
+ set flowDistortionB(value: number) {
643
+ this._flowDistortionB = value;
644
+ }
645
+
646
+ set flowScale(value: number) {
647
+ this._flowScale = value;
648
+ }
649
+
650
+ set flowEase(value: number) {
651
+ this._flowEase = value;
652
+ }
653
+
654
+ set flowEnabled(value: boolean) {
655
+ this._flowEnabled = value;
656
+ }
657
+
658
+ get flowEnabled(): boolean {
659
+ return this._flowEnabled;
660
+ }
661
+
662
+
663
+ set mouseDistortionStrength(value: number) {
664
+ this._mouseDistortionStrength = Math.max(0, value);
665
+ }
666
+
667
+ set mouseDistortionRadius(value: number) {
668
+ // Clamp to a sane range in UV space
669
+ this._mouseDistortionRadius = Math.max(0.01, Math.min(value, 1.0));
670
+ // Update brush scale when radius changes
671
+ this._updateBrushScale();
672
+ }
673
+
674
+ _updateBrushScale() {
675
+ if (!this._mouseObjects || this._mouseObjects.length === 0) return;
676
+ // Radius directly controls the brush scale
677
+ // Base geometry is 200px, so radius 0.25 = 50px, radius 1.0 = 200px
678
+ this._mouseBrushBaseScale = this._mouseDistortionRadius;
679
+ }
680
+
681
+ set mouseDecayRate(value: number) {
682
+ // Clamp between 0.9 (slow decay, more wobble) and 0.99 (fast decay, less wobble)
683
+ this._mouseDecayRate = Math.max(0.9, Math.min(value, 0.99));
684
+ }
685
+
686
+ set mouseDarken(value: number) {
687
+ this._mouseDarken = value;
688
+ }
689
+
690
+ set enableProceduralTexture(value: boolean) {
691
+ this._enableProceduralTexture = value;
692
+ if (value && !this._proceduralTexture) {
693
+ this._textureNeedsUpdate = true;
694
+ }
695
+ }
696
+
697
+ set textureVoidLikelihood(value: number) {
698
+ this._textureVoidLikelihood = value;
699
+ if (this._enableProceduralTexture) {
700
+ this._textureNeedsUpdate = true;
701
+ }
702
+ }
703
+
704
+ set textureVoidWidthMin(value: number) {
705
+ this._textureVoidWidthMin = value;
706
+ if (this._enableProceduralTexture) {
707
+ this._textureNeedsUpdate = true;
708
+ }
709
+ }
710
+
711
+ set textureVoidWidthMax(value: number) {
712
+ this._textureVoidWidthMax = value;
713
+ if (this._enableProceduralTexture) {
714
+ this._textureNeedsUpdate = true;
715
+ }
716
+ }
717
+
718
+ set textureBandDensity(value: number) {
719
+ this._textureBandDensity = value;
720
+ if (this._enableProceduralTexture) {
721
+ this._textureNeedsUpdate = true;
722
+ }
723
+ }
724
+
725
+ set textureColorBlending(value: number) {
726
+ this._textureColorBlending = value;
727
+ if (this._enableProceduralTexture) {
728
+ this._textureNeedsUpdate = true;
729
+ }
730
+ }
731
+
732
+ set textureSeed(value: number) {
733
+ this._textureSeed = value;
734
+ if (this._enableProceduralTexture) {
735
+ this._textureNeedsUpdate = true;
736
+ }
737
+ }
738
+
739
+ get textureEase(): number {
740
+ return this._textureEase;
741
+ }
742
+
743
+ set textureEase(value: number) {
744
+ this._textureEase = value;
745
+ }
746
+
747
+ set proceduralBackgroundColor(value: string) {
748
+ this._proceduralBackgroundColor = value;
749
+ if (this._enableProceduralTexture) {
750
+ this._textureNeedsUpdate = true;
751
+ }
752
+ }
753
+
754
+ set textureShapeTriangles(value: number) {
755
+ this._textureShapeTriangles = value;
756
+ if (this._enableProceduralTexture) this._textureNeedsUpdate = true;
757
+ }
758
+ set textureShapeCircles(value: number) {
759
+ this._textureShapeCircles = value;
760
+ if (this._enableProceduralTexture) this._textureNeedsUpdate = true;
761
+ }
762
+ set textureShapeBars(value: number) {
763
+ this._textureShapeBars = value;
764
+ if (this._enableProceduralTexture) this._textureNeedsUpdate = true;
765
+ }
766
+ set textureShapeSquiggles(value: number) {
767
+ this._textureShapeSquiggles = value;
768
+ if (this._enableProceduralTexture) this._textureNeedsUpdate = true;
769
+ }
770
+
351
771
  _initScene(resolution: number): SceneState {
352
772
 
353
773
  const width = this._ref.width,
354
774
  height = this._ref.height;
355
775
 
776
+ // Cleanup existing renderer if needed
777
+ if (this.sceneState && this.sceneState.renderer) {
778
+ this.sceneState.renderer.dispose();
779
+ this.sceneState.meshes.forEach(m => {
780
+ m.geometry.dispose();
781
+ if(Array.isArray(m.material)) m.material.forEach(mat => mat.dispose());
782
+ else m.material.dispose();
783
+ });
784
+ }
785
+
356
786
  const renderer = new THREE.WebGLRenderer({
357
787
  // antialias: true,
358
788
  alpha: true,
@@ -392,17 +822,13 @@ export class NeatGradient implements NeatController {
392
822
 
393
823
  _buildMaterial(width: number, height: number) {
394
824
 
395
- const colors = [
396
- ...this._colors.map(color => ({
397
- is_active: color.enabled,
398
- color: new THREE.Color(color.color),
399
- influence: color.influence
400
- })),
401
- ...Array.from({ length: COLORS_COUNT - this._colors.length }).map(() => ({
402
- is_active: false,
403
- color: new THREE.Color(0x000000)
404
- }))
405
- ];
825
+ // Initialize stable array structure for colors
826
+ // We create 6 objects and just update them in the render loop to avoid GC
827
+ const colors = Array.from({ length: COLORS_COUNT }).map((_, i) => ({
828
+ is_active: i < this._colors.length ? (this._colors[i].enabled ? 1.0 : 0.0) : 0.0,
829
+ color: new THREE.Color(i < this._colors.length ? this._colors[i].color : 0x000000),
830
+ influence: i < this._colors.length ? (this._colors[i].influence || 0) : 0
831
+ }));
406
832
 
407
833
  const uniforms = {
408
834
  u_time: { value: 0 },
@@ -421,6 +847,29 @@ export class NeatGradient implements NeatController {
421
847
  u_grain_sparsity: { value: this._grainSparsity },
422
848
  u_grain_scale: { value: this._grainScale },
423
849
  u_grain_speed: { value: this._grainSpeed },
850
+ // Flow field
851
+ u_flow_distortion_a: { value: this._flowDistortionA },
852
+ u_flow_distortion_b: { value: this._flowDistortionB },
853
+ u_flow_scale: { value: this._flowScale },
854
+ u_flow_ease: { value: this._flowEase },
855
+ u_flow_enabled: { value: this._flowEnabled ? 1.0 : 0.0 },
856
+ // Y offset multipliers
857
+ u_y_offset: { value: this._yOffset },
858
+ u_y_offset_wave_multiplier: { value: this._yOffsetWaveMultiplier },
859
+ u_y_offset_color_multiplier: { value: this._yOffsetColorMultiplier },
860
+ u_y_offset_flow_multiplier: { value: this._yOffsetFlowMultiplier },
861
+ // Mouse interaction
862
+ u_mouse_distortion_strength: { value: this._mouseDistortionStrength },
863
+ u_mouse_distortion_radius: { value: this._mouseDistortionRadius },
864
+ u_mouse_darken: { value: this._mouseDarken },
865
+ u_mouse_texture: { value: this._mouseFBO ? this._mouseFBO.texture : null },
866
+ // Procedural texture
867
+ u_procedural_texture: { value: this._proceduralTexture },
868
+ u_enable_procedural_texture: { value: this._enableProceduralTexture ? 1.0 : 0.0 },
869
+ u_texture_ease: { value: this._textureEase },
870
+ u_saturation: { value: this._saturation },
871
+ u_brightness: { value: this._brightness },
872
+ u_color_blending: { value: this._colorBlending }
424
873
  };
425
874
 
426
875
  const material = new THREE.ShaderMaterial({
@@ -429,10 +878,306 @@ export class NeatGradient implements NeatController {
429
878
  fragmentShader: buildUniforms() + buildColorFunctions() + buildNoise() + buildFragmentShader()
430
879
  });
431
880
 
881
+ // Cache the uniforms object for direct access in render loop
882
+ this._cachedUniforms = uniforms as unknown as NeatUniforms;
883
+
432
884
  material.wireframe = WIREFRAME;
433
885
  return material;
434
886
  }
435
887
 
888
+ _setupMouseInteraction() {
889
+ if (!this._ref) return;
890
+
891
+ const width = this._ref.width;
892
+ const height = this._ref.height;
893
+
894
+ // Create mouse FBO
895
+ this._mouseFBO = new THREE.WebGLRenderTarget(width / 2, height / 2);
896
+
897
+ // Create mouse scene and camera
898
+ this._sceneMouse = new THREE.Scene();
899
+ const fSize = height / 2;
900
+ const aspect = width / height;
901
+
902
+ // FIX 4: Ensure near plane allows viewing objects at Z=0
903
+ // Near -100 is safer for objects at 0
904
+ this._cameraMouse = new THREE.OrthographicCamera(
905
+ -fSize * aspect, fSize * aspect,
906
+ fSize, -fSize,
907
+ 0, 10000
908
+ );
909
+ this._cameraMouse.position.set(0, 0, 100);
910
+
911
+ // Create brush texture - More visible and impactful
912
+ const brushCanvas = document.createElement('canvas');
913
+ brushCanvas.width = 128;
914
+ brushCanvas.height = 128;
915
+ const bCtx = brushCanvas.getContext('2d');
916
+ if (bCtx) {
917
+ const grd = bCtx.createRadialGradient(64, 64, 0, 64, 64, 64);
918
+ // Match reference implementation's stronger gradient
919
+ grd.addColorStop(0, 'rgba(255,255,255,0.8)');
920
+ grd.addColorStop(0.5, 'rgba(255,255,255,0.4)');
921
+ grd.addColorStop(1, 'rgba(255,255,255,0)');
922
+ bCtx.fillStyle = grd;
923
+ bCtx.fillRect(0, 0, 128, 128);
924
+ }
925
+ const brushTex = new THREE.CanvasTexture(brushCanvas);
926
+ const brushMat = new THREE.MeshBasicMaterial({
927
+ map: brushTex,
928
+ transparent: true,
929
+ opacity: 1.0,
930
+ depthTest: false,
931
+ blending: THREE.AdditiveBlending // Additive blending for better accumulation
932
+ });
933
+ // Brush geometry size - will be scaled by radius parameter
934
+ const brushGeo = new THREE.PlaneGeometry(200, 200);
935
+
936
+ // Create brush pool
937
+ const brushPoolSize = 50;
938
+ for (let i = 0; i < brushPoolSize; i++) {
939
+ const m = new THREE.Mesh(brushGeo, brushMat.clone());
940
+ m.visible = false;
941
+ this._sceneMouse!.add(m);
942
+ this._mouseObjects.push({ mesh: m, active: false });
943
+ }
944
+
945
+ // Initialize brush scale based on current radius
946
+ this._updateBrushScale();
947
+
948
+ // Add mouse move listener
949
+ this._ref.addEventListener('mousemove', this._onMouseMove.bind(this));
950
+ }
951
+
952
+ _onMouseMove(e: MouseEvent) {
953
+ if (!this._ref || !this._sceneMouse) return;
954
+
955
+ const rect = this._ref.getBoundingClientRect();
956
+ const width = this._ref.width;
957
+ const height = this._ref.height;
958
+
959
+ // Store pending mouse position
960
+ this._pendingMousePosition = {
961
+ x: e.clientX - rect.left - width / 2,
962
+ y: -(e.clientY - rect.top - height / 2)
963
+ };
964
+
965
+ // Batch mouse updates using requestAnimationFrame
966
+ if (!this._mouseUpdateScheduled) {
967
+ this._mouseUpdateScheduled = true;
968
+ requestAnimationFrame(() => {
969
+ this._mouseUpdateScheduled = false;
970
+
971
+ if (!this._pendingMousePosition) return;
972
+
973
+ this._mouse.x = this._pendingMousePosition.x;
974
+ this._mouse.y = this._pendingMousePosition.y;
975
+
976
+ const brush = this._mouseObjects[this._currentBrush];
977
+ brush.mesh.scale.set(this._mouseBrushBaseScale, this._mouseBrushBaseScale, 1.0);
978
+ brush.active = true;
979
+ brush.mesh.visible = true;
980
+ brush.mesh.position.set(this._mouse.x, this._mouse.y, 0);
981
+ brush.mesh.rotation.z = Math.random() * Math.PI * 2;
982
+ if (brush.mesh.material instanceof THREE.MeshBasicMaterial) {
983
+ brush.mesh.material.opacity = 1.0;
984
+ }
985
+ this._currentBrush = (this._currentBrush + 1) % this._mouseObjects.length;
986
+
987
+ this._pendingMousePosition = null;
988
+ });
989
+ }
990
+ }
991
+
992
+ _createProceduralTexture(): THREE.Texture {
993
+ // Texture size - 1024 provides good balance between quality and performance
994
+ // Reduced from 2048 for better performance
995
+ const texSize = 1024;
996
+ const sourceCanvas = document.createElement('canvas');
997
+ sourceCanvas.width = texSize;
998
+ sourceCanvas.height = texSize;
999
+ const sCtx = sourceCanvas.getContext('2d', { willReadFrequently: true });
1000
+ if (!sCtx) return new THREE.Texture();
1001
+
1002
+ let seed = this._textureSeed;
1003
+ const baseSeed = this._textureSeed;
1004
+
1005
+ function random() {
1006
+ const x = Math.sin(seed++) * 10000;
1007
+ return x - Math.floor(x);
1008
+ }
1009
+
1010
+ // Helper to reset seed for isolated shape generation
1011
+ const setSeed = (offset: number) => {
1012
+ seed = baseSeed + offset;
1013
+ };
1014
+
1015
+ const colors = this._colors.filter(c => c.enabled).map(c => c.color);
1016
+ if (colors.length === 0) return new THREE.Texture();
1017
+
1018
+ // Helper functions
1019
+ function hexToRgb(hex: string) {
1020
+ const bigint = parseInt(hex.replace('#', ''), 16);
1021
+ return {
1022
+ r: (bigint >> 16) & 255,
1023
+ g: (bigint >> 8) & 255,
1024
+ b: bigint & 255
1025
+ };
1026
+ }
1027
+
1028
+ function rgbToHex(r: number, g: number, b: number) {
1029
+ return "#" + ((1 << 24) + (Math.round(r) << 16) + (Math.round(g) << 8) + Math.round(b)).toString(16).slice(1);
1030
+ }
1031
+
1032
+ const getInterColor = () => {
1033
+ const c1 = colors[Math.floor(random() * colors.length)];
1034
+ const c2 = colors[Math.floor(random() * colors.length)];
1035
+ const mix = random() * this._textureColorBlending;
1036
+ const rgb1 = hexToRgb(c1);
1037
+ const rgb2 = hexToRgb(c2);
1038
+ const r = rgb1.r + (rgb2.r - rgb1.r) * mix;
1039
+ const g = rgb1.g + (rgb2.g - rgb1.g) * mix;
1040
+ const b = rgb1.b + (rgb2.b - rgb1.b) * mix;
1041
+ return rgbToHex(r, g, b);
1042
+ };
1043
+
1044
+ // === SOURCE CANVAS ===
1045
+ // Base with procedural background color so even sparse areas pick it up
1046
+ const baseColor = this._proceduralBackgroundColor || "#000000";
1047
+ sCtx.fillStyle = baseColor;
1048
+ sCtx.fillRect(0, 0, texSize, texSize);
1049
+
1050
+ // Then lay a vertical gradient of mixed colors on top for richness
1051
+ const bgGrad = sCtx.createLinearGradient(0, 0, 0, texSize);
1052
+ bgGrad.addColorStop(0, getInterColor());
1053
+ bgGrad.addColorStop(1, getInterColor());
1054
+ sCtx.fillStyle = bgGrad;
1055
+ sCtx.fillRect(0, 0, texSize, texSize);
1056
+
1057
+ // Triangles: use configurable count
1058
+ for (let i = 0; i < this._textureShapeTriangles; i++) {
1059
+ sCtx.fillStyle = getInterColor();
1060
+ sCtx.beginPath();
1061
+ const x = random() * texSize;
1062
+ const y = random() * texSize;
1063
+ const s = 100 + random() * 300;
1064
+ sCtx.moveTo(x, y);
1065
+ sCtx.lineTo(x + (random() - 0.5) * s, y + (random() - 0.5) * s);
1066
+ sCtx.lineTo(x + (random() - 0.5) * s, y + (random() - 0.5) * s);
1067
+ sCtx.fill();
1068
+ }
1069
+
1070
+ // Circles / rings: use configurable count
1071
+ for (let i = 0; i < this._textureShapeCircles; i++) {
1072
+ sCtx.strokeStyle = getInterColor();
1073
+ sCtx.lineWidth = 10 + random() * 50;
1074
+ sCtx.beginPath();
1075
+ const x = random() * texSize;
1076
+ const y = random() * texSize;
1077
+ const r = 50 + random() * 150;
1078
+ sCtx.arc(x, y, r, 0, Math.PI * 2);
1079
+ sCtx.stroke();
1080
+ }
1081
+
1082
+ // Bars: use configurable count
1083
+ for (let i = 0; i < this._textureShapeBars; i++) {
1084
+ sCtx.fillStyle = getInterColor();
1085
+ sCtx.save();
1086
+ sCtx.translate(random() * texSize, random() * texSize);
1087
+ sCtx.rotate(random() * Math.PI);
1088
+ sCtx.fillRect(-150, -25, 300, 50);
1089
+ sCtx.restore();
1090
+ }
1091
+
1092
+ // Squiggles: use configurable count
1093
+ sCtx.lineWidth = 15;
1094
+ sCtx.lineCap = 'round';
1095
+ for (let i = 0; i < this._textureShapeSquiggles; i++) {
1096
+ sCtx.strokeStyle = getInterColor();
1097
+ sCtx.beginPath();
1098
+ let x = random() * texSize;
1099
+ let y = random() * texSize;
1100
+ sCtx.moveTo(x, y);
1101
+ for (let j = 0; j < 4; j++) {
1102
+ sCtx.bezierCurveTo(
1103
+ x + (random() - 0.5) * 300, y + (random() - 0.5) * 300,
1104
+ x + (random() - 0.5) * 300, y + (random() - 0.5) * 300,
1105
+ x + (random() - 0.5) * 300, y + (random() - 0.5) * 300
1106
+ );
1107
+ x += (random() - 0.5) * 300;
1108
+ y += (random() - 0.5) * 300;
1109
+ }
1110
+ sCtx.stroke();
1111
+ }
1112
+
1113
+ // === MASKED CANVAS ===
1114
+ // Masking: Seed isolation
1115
+ setSeed(50000);
1116
+ const canvas = document.createElement('canvas');
1117
+ canvas.width = texSize;
1118
+ canvas.height = texSize;
1119
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
1120
+ if (!ctx) return new THREE.Texture();
1121
+
1122
+ // Start filled with the chosen void color so gaps show that color
1123
+ ctx.fillStyle = baseColor;
1124
+ ctx.fillRect(0, 0, texSize, texSize);
1125
+
1126
+ // Determine layout segments (matter vs void)
1127
+ let layoutHead = 0;
1128
+ const segments: Array<{ type: 'void' | 'matter', x: number, width: number }> = [];
1129
+
1130
+ while (layoutHead < texSize) {
1131
+ const isVoid = random() < this._textureVoidLikelihood;
1132
+ if (isVoid) {
1133
+ const w = this._textureVoidWidthMin + random() * (this._textureVoidWidthMax - this._textureVoidWidthMin);
1134
+ segments.push({ type: 'void', x: layoutHead, width: w });
1135
+ layoutHead += w;
1136
+ } else {
1137
+ const w = 50 + random() * 200;
1138
+ segments.push({ type: 'matter', x: layoutHead, width: w });
1139
+ layoutHead += w;
1140
+ }
1141
+ }
1142
+
1143
+ // Render only matter bands from the source into the masked canvas
1144
+ for (const seg of segments) {
1145
+ if (seg.type === 'matter') {
1146
+ const startX = seg.x;
1147
+ const endX = Math.min(seg.x + seg.width, texSize);
1148
+ let currentX = startX;
1149
+
1150
+ while (currentX < endX) {
1151
+ const stripeWidth = (2 + random() * 20) / this._textureBandDensity;
1152
+ const sourceX = Math.floor(random() * texSize);
1153
+ ctx.drawImage(
1154
+ sourceCanvas,
1155
+ sourceX, 0, stripeWidth, texSize,
1156
+ currentX, 0, stripeWidth, texSize
1157
+ );
1158
+ currentX += stripeWidth;
1159
+ }
1160
+ }
1161
+ // void segments: leave as baseColor
1162
+ }
1163
+
1164
+ const tex = new THREE.CanvasTexture(canvas);
1165
+ // Use mipmapping for better quality when texture is scaled
1166
+ tex.minFilter = THREE.LinearMipmapLinearFilter;
1167
+ tex.magFilter = THREE.LinearFilter;
1168
+ tex.wrapS = THREE.RepeatWrapping;
1169
+ tex.wrapT = THREE.RepeatWrapping;
1170
+
1171
+ // Enable anisotropic filtering for much better quality when texture is stretched
1172
+ // 16 is a commonly supported value that dramatically improves quality
1173
+ tex.anisotropy = 16;
1174
+
1175
+ // Ensure mipmaps are generated
1176
+ tex.needsUpdate = true;
1177
+
1178
+ return tex;
1179
+ }
1180
+
436
1181
 
437
1182
  }
438
1183
 
@@ -449,11 +1194,26 @@ function updateCamera(camera: THREE.Camera, width: number, height: number) {
449
1194
  const targetWidth = Math.sqrt(targetPlaneArea * ratio);
450
1195
  const targetHeight = targetPlaneArea / targetWidth;
451
1196
 
452
- const left = -PLANE_WIDTH / 2;
453
- const right = Math.min((left + targetWidth) / 1.5, PLANE_WIDTH / 2);
454
-
455
- const top = PLANE_HEIGHT / 4;
456
- const bottom = Math.max((top - targetHeight) / 2, -PLANE_HEIGHT / 4);
1197
+ let left = -PLANE_WIDTH / 2;
1198
+ let right = Math.min((left + targetWidth) / 1.5, PLANE_WIDTH / 2);
1199
+
1200
+ let top = PLANE_HEIGHT / 4;
1201
+ let bottom = Math.max((top - targetHeight) / 2, -PLANE_HEIGHT / 4);
1202
+
1203
+ // Fix for mobile portrait: adjust bounds for proper aspect ratio AND zoom out slightly
1204
+ if (ratio < 1) {
1205
+ // Portrait mode - scale horizontal bounds by aspect ratio to prevent stretching
1206
+ const horizontalScale = ratio;
1207
+ left = left * horizontalScale;
1208
+ right = right * horizontalScale;
1209
+
1210
+ // Zoom out slightly on mobile (1.1 = 10% zoom out)
1211
+ const mobileZoomFactor = 1.05;
1212
+ left = left * mobileZoomFactor;
1213
+ right = right * mobileZoomFactor;
1214
+ top = top * mobileZoomFactor;
1215
+ bottom = bottom * mobileZoomFactor;
1216
+ }
457
1217
 
458
1218
  const near = -100;
459
1219
  const far = 1000;
@@ -473,43 +1233,69 @@ function updateCamera(camera: THREE.Camera, width: number, height: number) {
473
1233
  }
474
1234
 
475
1235
 
476
- function buildVertexShader() {
477
- return `
1236
+ // Cache shader strings to avoid repeated concatenation
1237
+ let cachedVertexShader: string | null = null;
1238
+ let cachedFragmentShader: string | null = null;
478
1239
 
1240
+ function buildVertexShader() {
1241
+ if (cachedVertexShader) return cachedVertexShader;
1242
+ cachedVertexShader = `
479
1243
  void main() {
480
-
481
1244
  vUv = uv;
482
1245
 
1246
+ // SCROLLING LOGIC
1247
+ // Separate multipliers for wave, color, and flow offsets
1248
+ float waveOffset = -u_y_offset * u_y_offset_wave_multiplier;
1249
+ float colorOffset = -u_y_offset * u_y_offset_color_multiplier;
1250
+ float flowOffset = -u_y_offset * u_y_offset_flow_multiplier;
1251
+
1252
+ // 1. DISPLACEMENT (WAVES)
1253
+ // We add waveOffset to Y to scroll the wave pattern
483
1254
  v_displacement_amount = cnoise( vec3(
484
1255
  u_wave_frequency_x * position.x + u_time,
485
- u_wave_frequency_y * position.y + u_time,
1256
+ u_wave_frequency_y * (position.y + waveOffset) + u_time,
486
1257
  u_time
487
1258
  ));
488
1259
 
489
- vec3 color;
1260
+ // 2. FLOW FIELD
1261
+ // Apply flow offset to scroll the flow field mask
1262
+ vec2 baseUv = vUv;
1263
+ baseUv.y += flowOffset / u_plane_height; // Scale to match wave speed
1264
+ vec2 flowUv = baseUv;
1265
+
1266
+ if (u_flow_enabled > 0.5) {
1267
+ if (u_flow_ease > 0.0 || u_flow_distortion_a > 0.0) {
1268
+ vec2 ppp = -1.0 + 2.0 * baseUv;
1269
+ ppp += 0.1 * cos((1.5 * u_flow_scale) * ppp.yx + 1.1 * u_time + vec2(0.1, 1.1));
1270
+ ppp += 0.1 * cos((2.3 * u_flow_scale) * ppp.yx + 1.3 * u_time + vec2(3.2, 3.4));
1271
+ ppp += 0.1 * cos((2.2 * u_flow_scale) * ppp.yx + 1.7 * u_time + vec2(1.8, 5.2));
1272
+ ppp += u_flow_distortion_a * cos((u_flow_distortion_b * u_flow_scale) * ppp.yx + 1.4 * u_time + vec2(6.3, 3.9));
1273
+
1274
+ float r = length(ppp);
1275
+ flowUv = mix(baseUv, vec2(baseUv.x * (1.0 - u_flow_ease) + r * u_flow_ease, baseUv.y), u_flow_ease);
1276
+ }
1277
+ }
490
1278
 
491
- // float t = mod(u_base_color, 100.0);
492
- color = u_colors[0].color;
1279
+ // Pass the standard flow UV to fragment shader (for mouse/texture)
1280
+ vFlowUv = flowUv;
493
1281
 
494
- // Apply y_offset to the noise coordinates
495
- vec2 noise_cord = vUv * u_color_pressure;
496
- // Apply the y-offset to shift the pattern vertically (1:1 pixel ratio)
497
- // Scale the offset to match the UV coordinate space
498
- float scaledOffset = u_y_offset / u_resolution.y;
499
- noise_cord.y -= scaledOffset;
1282
+ // 3. COLOR MIXING
1283
+ // We take the computed flow UVs and apply the color offset
1284
+ // Scale by plane height to match wave offset speed (world space vs UV space)
1285
+ vec3 color = u_colors[0].color;
1286
+ vec2 adjustedUv = flowUv;
1287
+ adjustedUv.y += colorOffset / u_plane_height; // Scroll the color mixing pattern
500
1288
 
1289
+ vec2 noise_cord = adjustedUv * u_color_pressure;
501
1290
  const float minNoise = .0;
502
1291
  const float maxNoise = .9;
503
1292
 
504
1293
  for (int i = 1; i < u_colors_count; i++) {
505
-
506
- if(u_colors[i].is_active == 1.0){
1294
+ if(u_colors[i].is_active > 0.5){
507
1295
  float noiseFlow = (1. + float(i)) / 30.;
508
1296
  float noiseSpeed = (1. + float(i)) * 0.11;
509
1297
  float noiseSeed = 13. + float(i) * 7.;
510
1298
 
511
- int reverseIndex = u_colors_count - i;
512
-
513
1299
  float noise = snoise(
514
1300
  vec3(
515
1301
  noise_cord.x * u_color_pressure.x + u_time * noiseFlow * 2.,
@@ -519,23 +1305,24 @@ void main() {
519
1305
  ) - (.1 * float(i)) + (.5 * u_color_blending);
520
1306
 
521
1307
  noise = clamp(minNoise, maxNoise + float(i) * 0.02, noise);
522
- vec3 nextColor = u_colors[i].color;
523
- color = mix(color, nextColor, smoothstep(0.0, u_color_blending, noise));
1308
+ color = mix(color, u_colors[i].color, smoothstep(0.0, u_color_blending, noise));
524
1309
  }
525
1310
  }
526
1311
 
527
1312
  v_color = color;
528
1313
 
1314
+ // 4. VERTEX POSITION
529
1315
  vec3 newPosition = position + normal * v_displacement_amount * u_wave_amplitude;
530
1316
  gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
531
-
532
1317
  v_new_position = gl_Position;
533
1318
  }
534
1319
  `;
1320
+ return cachedVertexShader;
535
1321
  }
536
1322
 
537
1323
  function buildFragmentShader() {
538
- return `
1324
+ if (cachedFragmentShader) return cachedFragmentShader;
1325
+ cachedFragmentShader = `
539
1326
  float random(vec2 p) {
540
1327
  return fract(sin(dot(p, vec2(12.9898,78.233))) * 43758.5453);
541
1328
  }
@@ -553,35 +1340,80 @@ float fbm(vec3 x) {
553
1340
  }
554
1341
 
555
1342
  void main() {
556
- vec3 color = v_color;
1343
+ // MOUSE DISTORTION
1344
+ vec2 finalUv = vFlowUv;
1345
+
1346
+ if (u_mouse_distortion_strength > 0.0) {
1347
+ vec4 mouseColor = texture2D(u_mouse_texture, vUv);
1348
+ float mouseValue = mouseColor.r;
1349
+
1350
+ if (mouseValue > 0.001) {
1351
+ float distortionAmount = mouseValue * u_mouse_distortion_strength;
1352
+ vec2 mouseDisp = vec2(distortionAmount, distortionAmount);
1353
+ finalUv -= mouseDisp;
1354
+ }
1355
+ }
1356
+
1357
+ vec3 baseColor;
1358
+
1359
+ if (u_enable_procedural_texture > 0.5) {
1360
+ // Calculate flow field distance for ease effect
1361
+ vec2 ppp = -1.0 + 2.0 * finalUv;
1362
+ ppp += 0.1 * cos((1.5 * u_flow_scale) * ppp.yx + 1.1 * u_time + vec2(0.1, 1.1));
1363
+ ppp += 0.1 * cos((2.3 * u_flow_scale) * ppp.yx + 1.3 * u_time + vec2(3.2, 3.4));
1364
+ ppp += 0.1 * cos((2.2 * u_flow_scale) * ppp.yx + 1.7 * u_time + vec2(1.8, 5.2));
1365
+ ppp += u_flow_distortion_a * cos((u_flow_distortion_b * u_flow_scale) * ppp.yx + 1.4 * u_time + vec2(6.3, 3.9));
1366
+ float r = length(ppp); // Flow distance
1367
+
1368
+ // Ease blending: 0 = topographic (flow), 1 = image (UV)
1369
+ float vx = (finalUv.x * u_texture_ease) + (r * (1.0 - u_texture_ease));
1370
+ float vy = (finalUv.y * u_texture_ease) + (0.0 * (1.0 - u_texture_ease));
1371
+ vec2 texUv = vec2(vx, vy);
1372
+
1373
+ // PARALLAX SCROLLING
1374
+ // We manually apply a smaller offset here to make the texture lag behind
1375
+ float parallaxFactor = 0.25; // 25% speed of the color mixing
1376
+ texUv.y -= (u_y_offset * u_y_offset_color_multiplier / u_plane_height) * parallaxFactor;
1377
+
1378
+ texUv *= 1.5; // Tiling scale
1379
+
1380
+ vec4 texSample = texture2D(u_procedural_texture, texUv);
1381
+ baseColor = texSample.rgb;
1382
+ } else {
1383
+ baseColor = v_color;
1384
+ }
1385
+
1386
+ vec3 color = baseColor;
1387
+
1388
+ // Post-processing
557
1389
  color += pow(v_displacement_amount, 1.0) * u_highlights;
558
1390
  color -= pow(1.0 - v_displacement_amount, 2.0) * u_shadows;
559
1391
  color = saturation(color, 1.0 + u_saturation);
560
1392
  color = color * u_brightness;
561
1393
 
562
- // Generate grain using fbm
1394
+ // Grain
563
1395
  vec2 noiseCoords = gl_FragCoord.xy / u_grain_scale;
564
1396
  float grain = (u_grain_speed != 0.0) ? fbm(vec3(noiseCoords, u_time * u_grain_speed)) : fbm(vec3(noiseCoords, 0.0));
565
1397
 
566
- // Center the grain around zero
567
1398
  grain = grain * 0.5 + 0.5;
568
1399
  grain -= 0.5;
569
-
570
- // Add sparsity control
571
1400
  grain = (grain > u_grain_sparsity) ? grain : 0.0;
572
-
573
- // Apply grain intensity
574
1401
  grain *= u_grain_intensity;
575
1402
 
576
- // Add grain to color
577
1403
  color += vec3(grain);
578
1404
 
579
1405
  gl_FragColor = vec4(color, 1.0);
580
1406
  }
581
1407
  `;
1408
+ return cachedFragmentShader;
582
1409
  }
583
1410
 
584
- const buildUniforms = () => `
1411
+ // Cache uniforms string as well
1412
+ let cachedUniformsShader: string | null = null;
1413
+
1414
+ const buildUniforms = () => {
1415
+ if (cachedUniformsShader) return cachedUniformsShader;
1416
+ cachedUniformsShader = `
585
1417
  precision highp float;
586
1418
 
587
1419
  struct Color {
@@ -613,83 +1445,98 @@ uniform float u_brightness;
613
1445
  uniform float u_color_blending;
614
1446
 
615
1447
  uniform int u_colors_count;
616
- uniform Color u_colors[5];
1448
+ uniform Color u_colors[6];
617
1449
  uniform vec2 u_resolution;
618
1450
 
619
1451
  uniform float u_y_offset;
1452
+ uniform float u_y_offset_wave_multiplier;
1453
+ uniform float u_y_offset_color_multiplier;
1454
+ uniform float u_y_offset_flow_multiplier;
1455
+
1456
+ // Flow field uniforms
1457
+ uniform float u_flow_distortion_a;
1458
+ uniform float u_flow_distortion_b;
1459
+ uniform float u_flow_scale;
1460
+ uniform float u_flow_ease;
1461
+ uniform float u_flow_enabled;
1462
+
1463
+ // Mouse interaction uniforms
1464
+ uniform float u_mouse_distortion_strength;
1465
+ uniform float u_mouse_distortion_radius;
1466
+ uniform float u_mouse_darken;
1467
+ uniform sampler2D u_mouse_texture;
1468
+
1469
+ // Procedural texture uniforms
1470
+ uniform sampler2D u_procedural_texture;
1471
+ uniform float u_enable_procedural_texture;
1472
+ uniform float u_texture_ease;
620
1473
 
621
1474
  varying vec2 vUv;
1475
+ varying vec2 vFlowUv;
622
1476
  varying vec4 v_new_position;
623
1477
  varying vec3 v_color;
624
1478
  varying float v_displacement_amount;
625
1479
 
626
1480
  `;
1481
+ return cachedUniformsShader;
1482
+ };
627
1483
 
628
- const buildNoise = () => `
1484
+ // Cache noise functions as well
1485
+ let cachedNoiseShader: string | null = null;
629
1486
 
630
- vec3 mod289(vec3 x)
631
- {
632
- return x - floor(x * (1.0 / 289.0)) * 289.0;
633
- }
1487
+ const buildNoise = () => {
1488
+ if (cachedNoiseShader) return cachedNoiseShader;
1489
+ cachedNoiseShader = `
634
1490
 
635
- vec4 mod289(vec4 x)
636
- {
637
- return x - floor(x * (1.0 / 289.0)) * 289.0;
1491
+ // 1. REPLACEMENT PERMUTE:
1492
+ // Uses a hash function (fract/sin) instead of a modular lookup table.
1493
+ vec4 permute(vec4 x) {
1494
+ return floor(fract(sin(x) * 43758.5453123) * 289.0);
638
1495
  }
639
1496
 
640
- vec4 permute(vec4 x)
641
- {
642
- return mod289(((x*34.0)+1.0)*x);
643
- }
644
-
645
- vec4 taylorInvSqrt(vec4 r)
646
- {
1497
+ // Taylor Inverse Sqrt
1498
+ vec4 taylorInvSqrt(vec4 r) {
647
1499
  return 1.79284291400159 - 0.85373472095314 * r;
648
1500
  }
649
1501
 
1502
+ // Fade function
650
1503
  vec3 fade(vec3 t) {
651
1504
  return t*t*t*(t*(t*6.0-15.0)+10.0);
652
1505
  }
653
1506
 
654
- float snoise(vec3 v)
655
- {
1507
+ // 3D Simplex Noise
1508
+ float snoise(vec3 v) {
656
1509
  const vec2 C = vec2(1.0/6.0, 1.0/3.0) ;
657
1510
  const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
658
1511
 
659
- // First corner
1512
+ // First corner
660
1513
  vec3 i = floor(v + dot(v, C.yyy) );
661
1514
  vec3 x0 = v - i + dot(i, C.xxx) ;
662
1515
 
663
- // Other corners
1516
+ // Other corners
664
1517
  vec3 g = step(x0.yzx, x0.xyz);
665
1518
  vec3 l = 1.0 - g;
666
1519
  vec3 i1 = min( g.xyz, l.zxy );
667
1520
  vec3 i2 = max( g.xyz, l.zxy );
668
1521
 
669
- // x0 = x0 - 0.0 + 0.0 * C.xxx;
670
- // x1 = x0 - i1 + 1.0 * C.xxx;
671
- // x2 = x0 - i2 + 2.0 * C.xxx;
672
- // x3 = x0 - 1.0 + 3.0 * C.xxx;
673
1522
  vec3 x1 = x0 - i1 + C.xxx;
674
- vec3 x2 = x0 - i2 + C.yyy; // 2.0*C.x = 1/3 = C.y
675
- vec3 x3 = x0 - D.yyy; // -1.0+3.0*C.x = -0.5 = -D.y
1523
+ vec3 x2 = x0 - i2 + C.yyy;
1524
+ vec3 x3 = x0 - D.yyy;
676
1525
 
677
- // Permutations
678
- i = mod289(i);
1526
+ // Permutations
679
1527
  vec4 p = permute( permute( permute(
680
1528
  i.z + vec4(0.0, i1.z, i2.z, 1.0 ))
681
1529
  + i.y + vec4(0.0, i1.y, i2.y, 1.0 ))
682
1530
  + i.x + vec4(0.0, i1.x, i2.x, 1.0 ));
683
1531
 
684
- // Gradients: 7x7 points over a square, mapped onto an octahedron.
685
- // The ring size 17*17 = 289 is close to a multiple of 49 (49*6 = 294)
1532
+ // Gradients
686
1533
  float n_ = 0.142857142857; // 1.0/7.0
687
1534
  vec3 ns = n_ * D.wyz - D.xzx;
688
1535
 
689
- vec4 j = p - 49.0 * floor(p * ns.z * ns.z); // mod(p,7*7)
1536
+ vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
690
1537
 
691
1538
  vec4 x_ = floor(j * ns.z);
692
- vec4 y_ = floor(j - 7.0 * x_ ); // mod(j,N)
1539
+ vec4 y_ = floor(j - 7.0 * x_ );
693
1540
 
694
1541
  vec4 x = x_ *ns.x + ns.yyyy;
695
1542
  vec4 y = y_ *ns.x + ns.yyyy;
@@ -698,8 +1545,6 @@ float snoise(vec3 v)
698
1545
  vec4 b0 = vec4( x.xy, y.xy );
699
1546
  vec4 b1 = vec4( x.zw, y.zw );
700
1547
 
701
- //vec4 s0 = vec4(lessThan(b0,0.0))*2.0 - 1.0;
702
- //vec4 s1 = vec4(lessThan(b1,0.0))*2.0 - 1.0;
703
1548
  vec4 s0 = floor(b0)*2.0 + 1.0;
704
1549
  vec4 s1 = floor(b1)*2.0 + 1.0;
705
1550
  vec4 sh = -step(h, vec4(0.0));
@@ -712,14 +1557,14 @@ float snoise(vec3 v)
712
1557
  vec3 p2 = vec3(a1.xy,h.z);
713
1558
  vec3 p3 = vec3(a1.zw,h.w);
714
1559
 
715
- //Normalise gradients
1560
+ // Normalise gradients
716
1561
  vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2, p2), dot(p3,p3)));
717
1562
  p0 *= norm.x;
718
1563
  p1 *= norm.y;
719
1564
  p2 *= norm.z;
720
1565
  p3 *= norm.w;
721
1566
 
722
- // Mix final noise value
1567
+ // Mix final noise value
723
1568
  vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);
724
1569
  m = m * m;
725
1570
  return 42.0 * dot( m*m, vec4( dot(p0,x0), dot(p1,x1),
@@ -729,12 +1574,11 @@ float snoise(vec3 v)
729
1574
  // Classic Perlin noise
730
1575
  float cnoise(vec3 P)
731
1576
  {
732
- vec3 Pi0 = floor(P); // Integer part for indexing
733
- vec3 Pi1 = Pi0 + vec3(1.0); // Integer part + 1
734
- Pi0 = mod289(Pi0);
735
- Pi1 = mod289(Pi1);
736
- vec3 Pf0 = fract(P); // Fractional part for interpolation
737
- vec3 Pf1 = Pf0 - vec3(1.0); // Fractional part - 1.0
1577
+ vec3 Pi0 = floor(P);
1578
+ vec3 Pi1 = Pi0 + vec3(1.0);
1579
+
1580
+ vec3 Pf0 = fract(P);
1581
+ vec3 Pf1 = Pf0 - vec3(1.0);
738
1582
  vec4 ix = vec4(Pi0.x, Pi1.x, Pi0.x, Pi1.x);
739
1583
  vec4 iy = vec4(Pi0.yy, Pi1.yy);
740
1584
  vec4 iz0 = Pi0.zzzz;
@@ -795,49 +1639,16 @@ float cnoise(vec3 P)
795
1639
  float n_xyz = mix(n_yz.x, n_yz.y, fade_xyz.x);
796
1640
  return 2.2 * n_xyz;
797
1641
  }
1642
+ `;
1643
+ return cachedNoiseShader;
1644
+ };
798
1645
 
799
- // YUV to RGB matrix
800
- mat3 yuv2rgb = mat3(1.0, 0.0, 1.13983,
801
- 1.0, -0.39465, -0.58060,
802
- 1.0, 2.03211, 0.0);
803
-
804
- // RGB to YUV matrix
805
- mat3 rgb2yuv = mat3(0.2126, 0.7152, 0.0722,
806
- -0.09991, -0.33609, 0.43600,
807
- 0.615, -0.5586, -0.05639);
808
-
809
- vec3 oklab2rgb(vec3 linear)
810
- {
811
- const mat3 im1 = mat3(0.4121656120, 0.2118591070, 0.0883097947,
812
- 0.5362752080, 0.6807189584, 0.2818474174,
813
- 0.0514575653, 0.1074065790, 0.6302613616);
814
-
815
- const mat3 im2 = mat3(+0.2104542553, +1.9779984951, +0.0259040371,
816
- +0.7936177850, -2.4285922050, +0.7827717662,
817
- -0.0040720468, +0.4505937099, -0.8086757660);
818
-
819
- vec3 lms = im1 * linear;
820
-
821
- return im2 * (sign(lms) * pow(abs(lms), vec3(1.0/3.0)));
822
- }
823
-
824
- vec3 rgb2oklab(vec3 oklab)
825
- {
826
- const mat3 m1 = mat3(+1.000000000, +1.000000000, +1.000000000,
827
- +0.396337777, -0.105561346, -0.089484178,
828
- +0.215803757, -0.063854173, -1.291485548);
829
-
830
- const mat3 m2 = mat3(+4.076724529, -1.268143773, -0.004111989,
831
- -3.307216883, +2.609332323, -0.703476310,
832
- +0.230759054, -0.341134429, +1.706862569);
833
- vec3 lms = m1 * oklab;
834
-
835
- return m2 * (lms * lms * lms);
836
- }
837
-
838
- `;
1646
+ // Cache color functions as well
1647
+ let cachedColorFunctionsShader: string | null = null;
839
1648
 
840
- const buildColorFunctions = () => `
1649
+ const buildColorFunctions = () => {
1650
+ if (cachedColorFunctionsShader) return cachedColorFunctionsShader;
1651
+ cachedColorFunctionsShader = `
841
1652
 
842
1653
  vec3 saturation(vec3 rgb, float adjustment) {
843
1654
  const vec3 W = vec3(0.2125, 0.7154, 0.0721);
@@ -881,7 +1692,8 @@ vec3 hsv2rgb(vec3 c)
881
1692
  return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
882
1693
  }
883
1694
  `;
884
-
1695
+ return cachedColorFunctionsShader;
1696
+ };
885
1697
 
886
1698
  const setLinkStyles = (link: HTMLAnchorElement) => {
887
1699
  link.id = LINK_ID;
@@ -902,19 +1714,20 @@ const setLinkStyles = (link: HTMLAnchorElement) => {
902
1714
  link.innerHTML = "NEAT";
903
1715
  }
904
1716
 
905
- const addNeatLink = (ref: HTMLCanvasElement) => {
1717
+ const addNeatLink = (ref: HTMLCanvasElement): HTMLAnchorElement => {
906
1718
  const existingLinks = ref.parentElement?.getElementsByTagName("a");
907
1719
  if (existingLinks) {
908
1720
  for (let i = 0; i < existingLinks.length; i++) {
909
1721
  if (existingLinks[i].id === LINK_ID) {
910
1722
  setLinkStyles(existingLinks[i]);
911
- return;
1723
+ return existingLinks[i];
912
1724
  }
913
1725
  }
914
1726
  }
915
1727
  const link = document.createElement("a");
916
1728
  setLinkStyles(link);
917
1729
  ref.parentElement?.appendChild(link);
1730
+ return link;
918
1731
  }
919
1732
 
920
1733
  function getElapsedSecondsInLastHour() {