Zum Inhalt

perlin.js

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
/**
 * perlin.js — Ken Perlin's "Improved Noise" in 3D and 4D, fBm, and color modes.
 *
 * Vanilla JS, no dependencies. An IIFE exports to `window.MTPerlin`.
 * The whole module is also available as a string under `MTPerlin.WORKER_SOURCE`,
 * so it can be loaded 1:1 into a Web Worker (Blob URL) — essential for the
 * smooth animation in the studio.
 *
 * Public API (`window.MTPerlin`):
 *   - `makePermutation(base)` → Uint8Array(512) — seeded permutation
 *   - `perlin3(perm, x, y, z)` → value in roughly [-1, 1]
 *   - `perlin4(perm, x, y, z, w)` → value in roughly [-1, 1]
 *   - `fbm3(perm, x, y, z, octaves, persistence, lacunarity)` → value in [0, 1]
 *   - `fbm4(perm, x, y, z, w, octaves, persistence, lacunarity)` → value in [0, 1]
 *   - `generatePerlinImage(params, ctx)` renders via the 3D/4D path into a canvas ctx
 *   - `colorize(noiseValue, params)` → `[r, g, b]` in 0–255
 *   - `shadeRelief(rgb, gx, gy, params)` → lit `[r, g, b]` (relief lighting, `params.reliefOn`)
 *   - `WORKER_SOURCE` — full module code as a string for inline workers
 *
 * Color modes (`params.colorMode`):
 *   - `"rgb"`       per-channel scale + invert (default, like the Python original)
 *   - `"gradient"`  color stops `[{pos:0..1, color:[r,g,b]}, ...]`, linearly interpolated
 *   - `"hsv"`       noise → hue, hsvSat/hsvVal as constants
 *   - `"posterize"` noise quantised to N steps, then mapped onto a gradient
 *   - `"cosine"`    a + b·cos(2π(c·n + d)) per channel, `params.cosine = {a,b,c,d}` (three numbers each)
 *
 * Animation loop (`params.loop = true`):
 *   Instead of running linearly through the time axis, the 4D noise traces a
 *   circular orbit in `(z, w) = (cos(α)·r, sin(α)·r)`. After one full
 *   revolution (α = 2π), z and w match the starting position
 *   → a mathematically seamless loop.
 *
 * @license AGPL-3.0-or-later
 */
(function () {
  "use strict";

  const BASE_PERM = [
    151,160,137,91,90,15,131,13,201,95,96,53,194,233,7,225,140,36,103,30,69,142,
    8,99,37,240,21,10,23,190,6,148,247,120,234,75,0,26,197,62,94,252,219,203,117,
    35,11,32,57,177,33,88,237,149,56,87,174,20,125,136,171,168,68,175,74,165,71,
    134,139,48,27,166,77,146,158,231,83,111,229,122,60,211,133,230,220,105,92,41,
    55,46,245,40,244,102,143,54,65,25,63,161,1,216,80,73,209,76,132,187,208,89,
    18,169,200,196,135,130,116,188,159,86,164,100,109,198,173,186,3,64,52,217,
    226,250,124,123,5,202,38,147,118,126,255,82,85,212,207,206,59,227,47,16,58,
    17,182,189,28,42,223,183,170,213,119,248,152,2,44,154,163,70,221,153,101,155,
    167,43,172,9,129,22,39,253,19,98,108,110,79,113,224,232,178,185,112,104,218,
    246,97,228,251,34,242,193,238,210,144,12,191,179,162,241,81,51,145,235,249,
    14,239,107,49,192,214,31,181,199,106,157,184,84,204,176,115,121,50,45,127,4,
    150,254,138,236,205,93,222,114,67,29,24,72,243,141,128,195,78,66,215,61,156,
    180
  ];

  /**
   * Builds a seeded permutation table for Perlin noise.
   * Shifts the classic `BASE_PERM` deterministically by `base` steps so every
   * integer seed produces a distinct noise field. The table is duplicated
   * (512 entries) so `perm[i + 1]` lookups avoid a modulo — faster in the
   * inner render loop.
   *
   * @param {number} base  Seed (any integer)
   * @returns {Uint8Array} 512 bytes, the permutation table
   */
  function makePermutation(base) {
    const offset = ((base | 0) % 256 + 256) % 256;
    const perm = new Uint8Array(512);
    for (let i = 0; i < 256; i++) perm[i] = BASE_PERM[(i + offset) & 255];
    for (let i = 256; i < 512; i++) perm[i] = perm[i - 256];
    return perm;
  }

  // Ken Perlin's "fade" polynomial 6t⁵ − 15t⁴ + 10t³: enforces C² continuity
  // at grid points (first and second derivative are zero there).

  function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
  function lerp(a, b, t) { return a + t * (b - a); }
  function grad(hash, x, y, z) {
    const h = hash & 15;
    const u = h < 8 ? x : y;
    const v = h < 4 ? y : (h === 12 || h === 14 ? x : z);
    return ((h & 1) === 0 ? u : -u) + ((h & 2) === 0 ? v : -v);
  }

  /**
   * 3D Perlin noise (Improved Noise, Ken Perlin 2002).
   * Value range roughly `[-0.88, 0.88]`. At grid points (integer
   * coordinates) it's exactly 0 — by design, not a bug.
   *
   * @param {Uint8Array} perm  Permutation from `makePermutation`
   * @param {number} x  Coordinate (arbitrary)
   * @param {number} y
   * @param {number} z
   * @returns {number}  Noise value in roughly `[-1, 1]`
   */
  // Wrap an integer cell index for tileable noise. `period=0` falls back
  // to the classic `& 255` infinite-noise behaviour — bit-identical to
  // the pre-tileable code, that's the regression guarantee.
  function wrapCell(v, period) {
    return period > 0 ? (((v % period) + period) % period) & 255 : v & 255;
  }
  function nextCell(v, period) {
    return period > 0 ? ((((v + 1) % period) + period) % period) & 255 : (v + 1) & 255;
  }

  function perlin3(perm, x, y, z, repeatX, repeatY) {
    const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z);
    const rx = repeatX || 0;
    const ry = repeatY || 0;
    const Xa = wrapCell(xi, rx), Xb = nextCell(xi, rx);
    const Ya = wrapCell(yi, ry), Yb = nextCell(yi, ry);
    const Za = zi & 255,         Zb = (zi + 1) & 255;
    x -= xi; y -= yi; z -= zi;
    const u = fade(x), v = fade(y), w = fade(z);

    // Eight-corner hash, written with explicit X/Y wrap so the tile
    // closes seamlessly. (Z does not tile — loop mode uses tiny Z and
    // doesn't benefit from spatial tiling on the time axis.)
    const Aa = perm[Xa] + Ya, Ab = perm[Xa] + Yb;
    const Ba = perm[Xb] + Ya, Bb = perm[Xb] + Yb;
    const Aa0 = perm[Aa] + Za, Aa1 = perm[Aa] + Zb;
    const Ab0 = perm[Ab] + Za, Ab1 = perm[Ab] + Zb;
    const Ba0 = perm[Ba] + Za, Ba1 = perm[Ba] + Zb;
    const Bb0 = perm[Bb] + Za, Bb1 = perm[Bb] + Zb;

    return lerp(
      lerp(
        lerp(grad(perm[Aa0], x,     y,     z),     grad(perm[Ba0], x - 1, y,     z),     u),
        lerp(grad(perm[Ab0], x,     y - 1, z),     grad(perm[Bb0], x - 1, y - 1, z),     u),
        v
      ),
      lerp(
        lerp(grad(perm[Aa1], x,     y,     z - 1), grad(perm[Ba1], x - 1, y,     z - 1), u),
        lerp(grad(perm[Ab1], x,     y - 1, z - 1), grad(perm[Bb1], x - 1, y - 1, z - 1), u),
        v
      ),
      w
    );
  }

  /**
   * fBm (Fractal Brownian Motion) on top of `perlin3`.
   * Sums several octaves with progressively higher frequency and smaller
   * amplitude. Normalised to `[0, 1]`.
   *
   * @param {Uint8Array} perm
   * @param {number} x
   * @param {number} y
   * @param {number} z
   * @param {number} octaves      Octave count (1–10 is sensible)
   * @param {number} persistence  Amplitude factor per octave (0.5 = standard)
   * @param {number} lacunarity   Frequency factor per octave (2.0 = standard)
   * @returns {number} Value in `[0, 1]`
   */
  function fbm3(perm, x, y, z, octaves, persistence, lacunarity, repeatX, repeatY) {
    const rx0 = repeatX || 0;
    const ry0 = repeatY || 0;
    let amp = 1, freq = 1, sum = 0, norm = 0;
    for (let o = 0; o < octaves; o++) {
      // Tile period scales with octave frequency so all octaves agree on
      // where the seam is. Rounded to the nearest integer cell — small
      // mismatch for non-integer lacunarity is unavoidable, but with the
      // default lacunarity = 2 every octave lands on an integer multiple.
      const rx = rx0 > 0 ? Math.round(rx0 * freq) : 0;
      const ry = ry0 > 0 ? Math.round(ry0 * freq) : 0;
      sum  += amp * perlin3(perm, x * freq, y * freq, z * freq, rx, ry);
      norm += amp;
      amp  *= persistence;
      freq *= lacunarity;
    }
    const n = norm > 0 ? sum / norm : 0;
    return (n + 1) * 0.5;
  }

  // ---------- 4D NOISE (for loopable animation) ----------
  // 32 4D gradient vectors from Ken Perlin's Improved Noise reference (Java).
  // Each of the 16 corners of a 4D hypercube gets one of these gradients via
  // a hash; the dot product with the offset vector gives that corner's
  // contribution. A circular path in (z, w) yields a perfectly seamless loop
  // because cos/sin return to the starting position after 2π.
  const GRAD4 = [
    [0,1,1,1],  [0,1,1,-1],  [0,1,-1,1],  [0,1,-1,-1],
    [0,-1,1,1], [0,-1,1,-1], [0,-1,-1,1], [0,-1,-1,-1],
    [1,0,1,1],  [1,0,1,-1],  [1,0,-1,1],  [1,0,-1,-1],
    [-1,0,1,1], [-1,0,1,-1], [-1,0,-1,1], [-1,0,-1,-1],
    [1,1,0,1],  [1,1,0,-1],  [1,-1,0,1],  [1,-1,0,-1],
    [-1,1,0,1], [-1,1,0,-1], [-1,-1,0,1], [-1,-1,0,-1],
    [1,1,1,0],  [1,1,-1,0],  [1,-1,1,0],  [1,-1,-1,0],
    [-1,1,1,0], [-1,1,-1,0], [-1,-1,1,0], [-1,-1,-1,0],
  ];

  function grad4(hash, x, y, z, w) {
    const g = GRAD4[hash & 31];
    return g[0] * x + g[1] * y + g[2] * z + g[3] * w;
  }

  /**
   * 4D Perlin noise. Same idea as `perlin3`, but with 16 hypercube corners
   * and a 4-deep nested lerp. The studio only uses it for loop mode: a
   * circular orbit in `(z, w)` closes seamlessly.
   *
   * @param {Uint8Array} perm
   * @param {number} x
   * @param {number} y
   * @param {number} z
   * @param {number} w
   * @returns {number} Noise value in roughly `[-1, 1]`
   */
  function perlin4(perm, x, y, z, w, repeatX, repeatY) {
    const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z), wi = Math.floor(w);
    const rx = repeatX || 0;
    const ry = repeatY || 0;
    const Xa = wrapCell(xi, rx), Xb = nextCell(xi, rx);
    const Ya = wrapCell(yi, ry), Yb = nextCell(yi, ry);
    const Za = zi & 255,         Zb = (zi + 1) & 255;
    const Wa = wi & 255,         Wb = (wi + 1) & 255;
    x -= xi; y -= yi; z -= zi; w -= wi;
    const u = fade(x), v = fade(y), s = fade(z), tt = fade(w);

    // Hash tree along X → Y → Z → W. Suffixes: a/b = low/high cell per axis,
    // chosen explicitly so X and Y can wrap for tileability while Z and W
    // pass through unchanged. The internal +1 lookups still go through the
    // duplicated perm table; only the X / Y entry points are wrap-aware.
    const Aa  = perm[Xa] + Ya;
    const Ab  = perm[Xa] + Yb;
    const Ba  = perm[Xb] + Ya;
    const Bb  = perm[Xb] + Yb;
    const AA  = perm[Aa] + Za;
    const AB  = perm[Ab] + Za;
    const BA  = perm[Ba] + Za;
    const BB  = perm[Bb] + Za;
    const AAA = perm[AA] + Wa, AAB = perm[AA + 1] + Wa;
    const ABA = perm[AB] + Wa, ABB = perm[AB + 1] + Wa;
    const BAA = perm[BA] + Wa, BAB = perm[BA + 1] + Wa;
    const BBA = perm[BB] + Wa, BBB = perm[BB + 1] + Wa;

    // 16 corners → 4-deep nested lerp.
    // Hash convention: AAB = Z shift (perm[AA+1]+W), AAA+1 = W shift (perm[AA]+W+1).
    // → Offsets must line up: AAB uses z-1, AAA+1 uses w-1.
    // Lerp order from inside out: u (X) → v (Y) → s (Z) → tt (W).
    return lerp(
      // W-low half
      lerp(
        // Z-low
        lerp(
          lerp(grad4(perm[AAA],     x,     y,     z,     w), grad4(perm[BAA],     x - 1, y,     z,     w),     u),
          lerp(grad4(perm[ABA],     x,     y - 1, z,     w), grad4(perm[BBA],     x - 1, y - 1, z,     w),     u),
          v
        ),
        // Z-high  (AAB = perm[AA+1]+W is a Z shift → offset z-1, w stays)
        lerp(
          lerp(grad4(perm[AAB],     x,     y,     z - 1, w), grad4(perm[BAB],     x - 1, y,     z - 1, w),     u),
          lerp(grad4(perm[ABB],     x,     y - 1, z - 1, w), grad4(perm[BBB],     x - 1, y - 1, z - 1, w),     u),
          v
        ),
        s
      ),
      // W-high half  (AAA+1 is a W shift → offset w-1, z stays)
      lerp(
        // Z-low
        lerp(
          lerp(grad4(perm[AAA + 1], x,     y,     z,     w - 1), grad4(perm[BAA + 1], x - 1, y,     z,     w - 1), u),
          lerp(grad4(perm[ABA + 1], x,     y - 1, z,     w - 1), grad4(perm[BBA + 1], x - 1, y - 1, z,     w - 1), u),
          v
        ),
        // Z-high  (AAB+1 = (Z shift)+1 = Z and W shift combined → offset z-1, w-1)
        lerp(
          lerp(grad4(perm[AAB + 1], x,     y,     z - 1, w - 1), grad4(perm[BAB + 1], x - 1, y,     z - 1, w - 1), u),
          lerp(grad4(perm[ABB + 1], x,     y - 1, z - 1, w - 1), grad4(perm[BBB + 1], x - 1, y - 1, z - 1, w - 1), u),
          v
        ),
        s
      ),
      tt
    );
  }

  /** fBm on top of `perlin4`. Value range `[0, 1]`. */
  function fbm4(perm, x, y, z, w, octaves, persistence, lacunarity, repeatX, repeatY) {
    const rx0 = repeatX || 0;
    const ry0 = repeatY || 0;
    let amp = 1, freq = 1, sum = 0, norm = 0;
    for (let o = 0; o < octaves; o++) {
      const rx = rx0 > 0 ? Math.round(rx0 * freq) : 0;
      const ry = ry0 > 0 ? Math.round(ry0 * freq) : 0;
      sum  += amp * perlin4(perm, x * freq, y * freq, z * freq, w * freq, rx, ry);
      norm += amp;
      amp  *= persistence;
      freq *= lacunarity;
    }
    const n = norm > 0 ? sum / norm : 0;
    return (n + 1) * 0.5;
  }

  // ---------- CELLULAR FIELDS (Voronoi) ----------
  // The second kind of field next to Perlin: distance to the nearest of a jittered grid of feature
  // points (Worley / Voronoi). The grid hash is the SAME permutation table as the Perlin noise, so
  // `base` seeds it and tiling wraps cells the same way. Time does not move a third axis: each
  // point circles inside its own cell with a per-cell phase, so the field repeats after one turn.
  // That makes a seamless loop with a plain 2D 3x3 search (a 4D Worley would search 81 cells).
  // `turns` is the fraction of a full turn, folded into [0, 1) by the caller in float64.
  //
  //   kind "cells"   F1, distance to the nearest point (dark at the points)
  //   kind "borders" F2 - F1, close to 0 on the borders between cells
  //   kind "smooth"  -ln(sum e^(-16 d)) / 16, a smooth minimum: soft blobs (Quilez, "Smooth Voronoi")
  //
  // Octaves add finer cells exactly like fBm adds finer noise; `jitter` 0 is a regular grid, 1 is
  // fully random inside each cell. The point never leaves its own cell, which is what makes the
  // 3x3 search exact.
  function cellFbm(perm, x, y, turns, octaves, persistence, lacunarity, repeatX, repeatY, kind, jitter) {
    const rx0 = repeatX || 0;
    const ry0 = repeatY || 0;
    let amp = 1, freq = 1, sum = 0, norm = 0;
    for (let o = 0; o < octaves; o++) {
      const rx = rx0 > 0 ? Math.round(rx0 * freq) : 0;
      const ry = ry0 > 0 ? Math.round(ry0 * freq) : 0;
      const px = x * freq, py = y * freq;
      const xi = Math.floor(px), yi = Math.floor(py);
      const fx = px - xi, fy = py - yi;
      let f1 = 8, f2 = 8, acc = 0;
      for (let j = -1; j <= 1; j++) {
        for (let i = -1; i <= 1; i++) {
          const cx = wrapCell(xi + i, rx), cy = wrapCell(yi + j, ry);
          const h1 = perm[perm[cx] + cy];
          const h2 = perm[perm[(cx + 53) & 255] + ((cy + 97) & 255)];
          const ox = 0.5 + 0.5 * jitter * Math.sin(Math.PI * 2 * (turns + h1 / 256));
          const oy = 0.5 + 0.5 * jitter * Math.sin(Math.PI * 2 * (turns + h2 / 256));
          const dx = i + ox - fx, dy = j + oy - fy;
          const d = Math.sqrt(dx * dx + dy * dy);
          if (d < f1) { f2 = f1; f1 = d; } else if (d < f2) { f2 = d; }
          acc += Math.exp(-16 * d);
        }
      }
      let v = kind === "borders" ? f2 - f1 : kind === "smooth" ? -Math.log(acc) / 16 : f1;
      v = v < 0 ? 0 : v > 1 ? 1 : v;
      sum += amp * v;
      norm += amp;
      amp *= persistence;
      freq *= lacunarity;
    }
    return norm > 0 ? sum / norm : 0;
  }

  // ---------- DOMAIN WARPING ----------
  // Inigo-Quilez-style domain warp: displace the sample coordinate by a
  // second, smoother noise field before running the main fbm. Tiny code,
  // huge visual change — Perlin output stops looking like "Perlin" and
  // starts looking like marble veins, ink in water, alien terrain.
  //
  // The displacement uses single-octave `perlin3/4` (not fbm) — cheap,
  // smooth, and detail isn't useful in the warp field. Two arbitrary
  // offsets (137/23 and 211/-53) decorrelate the x and y displacements
  // from each other and from the main field, otherwise you'd get
  // diagonal streaking.
  //
  // `warpStrength=0` skips the warp entirely → byte-identical output to
  // the un-warped fbm path. That's the regression guarantee.

  // When tile is on AND warp is on, warpScale must be integer for the
  // tile to actually close — the warp samples in (x*s + offset) space,
  // so for the seam to coincide we need (tileWidth * s) to be an integer
  // multiple of the warp's wrap period. The simplest correctness-first
  // rule: round warpScale to the nearest non-zero integer whenever both
  // features are co-active. Outside this case the user keeps the full
  // sub-1.0 fidelity of the warp.
  function effectiveWarpScale(warpScale, repeatX, repeatY) {
    const s = warpScale || 1;
    if ((repeatX > 0 || repeatY > 0) && warpScale) return Math.max(1, Math.round(s));
    return s;
  }

  function warpedFbm3(perm, x, y, z, octaves, persistence, lacunarity, warpStrength, warpScale, repeatX, repeatY, fld) {
    if (!warpStrength) {
      return fld ? cellFbm(perm, x, y, fld.turns, octaves, persistence, lacunarity, repeatX, repeatY, fld.kind, fld.jitter)
                 : fbm3(perm, x, y, z, octaves, persistence, lacunarity, repeatX, repeatY);
    }
    const s = effectiveWarpScale(warpScale, repeatX, repeatY);
    // Warp's perlin3 wraps at (tile * s) so the seam in main space coincides.
    // With integer s (guaranteed above when tile is on), this is always integer.
    const wrx = repeatX > 0 ? repeatX * s : 0;
    const wry = repeatY > 0 ? repeatY * s : 0;
    const dx = perlin3(perm, x * s + 137.0, y * s +  23.0, z, wrx, wry) * warpStrength;
    const dy = perlin3(perm, x * s + 211.0, y * s -  53.0, z, wrx, wry) * warpStrength;
    return fld ? cellFbm(perm, x + dx, y + dy, fld.turns, octaves, persistence, lacunarity, repeatX, repeatY, fld.kind, fld.jitter)
               : fbm3(perm, x + dx, y + dy, z, octaves, persistence, lacunarity, repeatX, repeatY);
  }

  function warpedFbm4(perm, x, y, z, w, octaves, persistence, lacunarity, warpStrength, warpScale, repeatX, repeatY, fld) {
    if (!warpStrength) {
      return fld ? cellFbm(perm, x, y, fld.turns, octaves, persistence, lacunarity, repeatX, repeatY, fld.kind, fld.jitter)
                 : fbm4(perm, x, y, z, w, octaves, persistence, lacunarity, repeatX, repeatY);
    }
    const s = effectiveWarpScale(warpScale, repeatX, repeatY);
    const wrx = repeatX > 0 ? repeatX * s : 0;
    const wry = repeatY > 0 ? repeatY * s : 0;
    const dx = perlin4(perm, x * s + 137.0, y * s +  23.0, z, w, wrx, wry) * warpStrength;
    const dy = perlin4(perm, x * s + 211.0, y * s -  53.0, z, w, wrx, wry) * warpStrength;
    return fld ? cellFbm(perm, x + dx, y + dy, fld.turns, octaves, persistence, lacunarity, repeatX, repeatY, fld.kind, fld.jitter)
               : fbm4(perm, x + dx, y + dy, z, w, octaves, persistence, lacunarity, repeatX, repeatY);
  }

  // ---------- LAYER BLENDING ----------
  // Composite two noise samples in [0, 1] with one of a few classic
  // blend modes. The studio uses these to stack a second noise field
  // ("Layer 2") on top of the main one — e.g. fine grain over slow
  // rolling hills, or a contour grid multiplied with a colour field.
  // All modes return a value in [0, 1] so the downstream `colorize`
  // pipeline is unchanged.

  function blendLayers(n1, n2, mode) {
    if (mode === "multiply") return n1 * n2;
    if (mode === "add")      return n1 + n2 > 1 ? 1 : n1 + n2;
    if (mode === "screen")   return 1 - (1 - n1) * (1 - n2);
    if (mode === "darken")   return n1 < n2 ? n1 : n2;
    if (mode === "lighten")  return n1 > n2 ? n1 : n2;
    if (mode === "difference") {
      const d = n1 - n2;
      return d < 0 ? -d : d;
    }
    return n1; // unknown mode — Layer 2 ignored
  }

  // ---------- COLOR MODES ----------

  function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }

  // Linear interpolation over a gradient-stop list.
  // stops: [{pos: 0..1, color: [r,g,b]}, ...] sorted by pos.
  function gradientLookup(stops, t) {
    t = clamp01(t);
    if (stops.length === 0) return [0, 0, 0];
    if (stops.length === 1) return stops[0].color.slice();
    if (t <= stops[0].pos) return stops[0].color.slice();
    const last = stops[stops.length - 1];
    if (t >= last.pos) return last.color.slice();
    for (let i = 1; i < stops.length; i++) {
      const a = stops[i - 1], b = stops[i];
      if (t <= b.pos) {
        const span = b.pos - a.pos;
        const u = span > 0 ? (t - a.pos) / span : 0;
        return [
          Math.round(a.color[0] + (b.color[0] - a.color[0]) * u),
          Math.round(a.color[1] + (b.color[1] - a.color[1]) * u),
          Math.round(a.color[2] + (b.color[2] - a.color[2]) * u),
        ];
      }
    }
    return last.color.slice();
  }

  // HSV → RGB, h in [0,1), s/v in [0,1]
  function hsvToRgb(h, s, v) {
    h = ((h % 1) + 1) % 1;
    const i = Math.floor(h * 6);
    const f = h * 6 - i;
    const p = v * (1 - s);
    const q = v * (1 - f * s);
    const t = v * (1 - (1 - f) * s);
    let r, g, b;
    switch (i % 6) {
      case 0: r = v; g = t; b = p; break;
      case 1: r = q; g = v; b = p; break;
      case 2: r = p; g = v; b = t; break;
      case 3: r = p; g = q; b = v; break;
      case 4: r = t; g = p; b = v; break;
      case 5: r = v; g = p; b = q; break;
    }
    return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
  }

  // ---------- RGB MULTI-CHANNEL (per-channel offset noise) ----------
  // When the studio's RGB mode runs in "multi-channel" mode, three
  // independent noise samples (taken at offset positions, but from the
  // same permutation) feed the R / G / B channels separately. The result:
  // colour channels drift independently, producing nebula-/film-grain-
  // like outputs that the single-noise RGB mode can't reach.

  function colorizeRgbMulti(nr, ng, nb, p) {
    const r = (p.rInvert ? 1 - nr : nr) * (p.rScale ?? 1);
    const g = (p.gInvert ? 1 - ng : ng) * (p.gScale ?? 1);
    const b = (p.bInvert ? 1 - nb : nb) * (p.bScale ?? 1);
    return [
      Math.max(0, Math.min(255, Math.round(r * 255))),
      Math.max(0, Math.min(255, Math.round(g * 255))),
      Math.max(0, Math.min(255, Math.round(b * 255))),
    ];
  }

  // ---------- FLOW FIELD ----------
  // Instead of mapping the noise *value* to colour, map the local
  // *gradient direction* — i.e. visualise the noise as a vector field.
  // Hue = atan2(dy, dx); magnitude tints brightness. Renders Perlin as
  // a flow chart of how it'd push a particle around. The render loops
  // sample a 3-point stencil per pixel and pass dx / dy here.

  function colorizeFlow(dx, dy, p) {
    const angle = Math.atan2(dy, dx); // (-π, π]
    // Map angle to hue with an optional shift so users can rotate the
    // colour wheel. (angle/(2π) + 0.5) brings it into [0, 1).
    let hue = angle / (Math.PI * 2) + 0.5 + (p.flowHueShift || 0);
    hue = ((hue % 1) + 1) % 1;
    const mag = Math.sqrt(dx * dx + dy * dy);
    // Magnitude can exceed 1 for noisy/warped fields. Scale by flowGain
    // (default 1) and clamp. flowFloor lifts the darkest pixels so the
    // image doesn't dissolve into black where the noise is flat.
    const gain = p.flowGain ?? 1.0;
    const floor = p.flowFloor ?? 0.0;
    const v = Math.max(0, Math.min(1, floor + mag * gain * (1 - floor)));
    const sat = p.flowSat ?? 0.85;
    return hsvToRgb(hue, sat, v);
  }

  // ---------- RELIEF LIGHTING ----------
  // Treat the noise as a height field and light it. The surface slope comes from the same
  // 3-point stencil the flow mode uses (so it works with warp, tiling, layer 2 and the loop
  // without a derivative per feature); (gx, gy) are slopes per noise-space unit, y pointing
  // down like the image. Colour comes from any colour mode, the light only scales it:
  // a flat surface keeps its colour (shade is divided by the flat-surface value), slopes
  // facing the light brighten, slopes facing away darken, and a Blinn-Phong highlight sits
  // on top. Azimuth runs clockwise from the top of the image (0 = light from above, 90 = from
  // the right), elevation is degrees above the surface.
  function shadeRelief(c, gx, gy, p) {
    const gain = p.reliefGain != null ? p.reliefGain : 1;
    let nx = -gain * gx, ny = -gain * gy, nz = 1;
    const inv = 1 / Math.sqrt(nx * nx + ny * ny + nz * nz);
    nx *= inv; ny *= inv; nz *= inv;
    const az = (p.lightAzimuth != null ? p.lightAzimuth : 315) * Math.PI / 180;
    const el = (p.lightElevation != null ? p.lightElevation : 45) * Math.PI / 180;
    const lx = Math.sin(az) * Math.cos(el), ly = -Math.cos(az) * Math.cos(el), lz = Math.sin(el);
    const amb = p.reliefAmbient != null ? p.reliefAmbient : 0.35;
    const flat = Math.max(1e-3, amb + (1 - amb) * lz);
    const shade = (amb + (1 - amb) * Math.max(0, nx * lx + ny * ly + nz * lz)) / flat;
    // Half vector for a viewer straight above the image; highlight relative to a flat surface.
    let hx = lx, hy = ly, hz = lz + 1;
    const hl = Math.sqrt(hx * hx + hy * hy + hz * hz);
    hx /= hl; hy /= hl; hz /= hl;
    const shine = p.reliefShine != null ? p.reliefShine : 32;
    const amt = p.reliefSpec != null ? p.reliefSpec : 0.25;
    const hit = Math.max(0, nx * hx + ny * hy + nz * hz);
    const hitFlat = Math.max(0, hz);
    const spec = amt * Math.max(0, (hit > 0 ? Math.pow(hit, shine) : 0) - (hitFlat > 0 ? Math.pow(hitFlat, shine) : 0)) * 255;
    return [
      Math.max(0, Math.min(255, Math.round(c[0] * shade + spec))),
      Math.max(0, Math.min(255, Math.round(c[1] * shade + spec))),
      Math.max(0, Math.min(255, Math.round(c[2] * shade + spec))),
    ];
  }

  /**
   * Maps a noise value to RGB. Branches on `params.colorMode`.
   *
   * @param {number} n  Noise value in `[0, 1]`
   * @param {Object} p  Studio parameters (see the module header for color modes)
   * @returns {[number, number, number]}  RGB in 0–255
   */
  function colorize(n, p) {
    const mode = p.colorMode || "rgb";
    if (mode === "rgb") {
      const r = (p.rInvert ? 1 - n : n) * (p.rScale ?? 0);
      const g = (p.gInvert ? 1 - n : n) * (p.gScale ?? 1);
      const b = (p.bInvert ? 1 - n : n) * (p.bScale ?? 0);
      return [
        Math.max(0, Math.min(255, Math.round(r * 255))),
        Math.max(0, Math.min(255, Math.round(g * 255))),
        Math.max(0, Math.min(255, Math.round(b * 255))),
      ];
    }
    if (mode === "gradient") {
      return gradientLookup(p.gradient || [], n);
    }
    if (mode === "hsv") {
      const hue = ((p.hsvHueShift ?? 0) + n * (p.hsvHueRange ?? 1)) % 1;
      return hsvToRgb(hue, p.hsvSat ?? 0.8, p.hsvVal ?? 1.0);
    }
    if (mode === "posterize") {
      const steps = Math.max(2, p.posterizeSteps || 5);
      const quantized = Math.floor(n * steps) / (steps - 1);
      return gradientLookup(p.gradient || [], Math.min(1, quantized));
    }
    if (mode === "cosine") {
      // Inigo Quilez' cosine palette (iquilezles.org/articles/palettes, code snippets MIT):
      // four 3-vectors give a whole colour ramp, no stop list. Kept inline (Math only) so
      // colorize.toString() carries it into the worker unchanged.
      const q = p.cosine || {};
      const a = q.a || [0.5, 0.5, 0.5], b = q.b || [0.5, 0.5, 0.5];
      const c = q.c || [1, 1, 1], d = q.d || [0, 0.33, 0.67];
      const out = [0, 0, 0];
      for (let k = 0; k < 3; k++) {
        const v = a[k] + b[k] * Math.cos(Math.PI * 2 * (c[k] * n + d[k]));
        out[k] = Math.max(0, Math.min(255, Math.round(v * 255)));
      }
      return out;
    }
    return [0, 0, 0];
  }

  /**
   * Renders a Perlin image into a 2D canvas context. Internally branches
   * between the 3D path (`params.loop = false`) and the 4D path (loop).
   *
   * Synchronous — for animations use the worker via `WORKER_SOURCE`,
   * otherwise the main thread blocks.
   *
   * @param {Object} params  Studio parameters (see the module header)
   * @param {CanvasRenderingContext2D} ctx
   */
  function generatePerlinImage(params, ctx) {
    const {
      width, height, t,
      scaleX, scaleY, scaleT,
      octaves, persistence, lacunarity, base,
      loop, loopProgress, loopRadius,
      warpStrength, warpScale,
      tileX, tileY,
      layer2On, layer2ScaleX, layer2ScaleY, layer2Octaves, layer2Base,
      layer2BlendMode, layer2Mix,
      colorMode, rgbMultiChannel,
    } = params;
    const ws = warpStrength || 0;
    const wsc = warpScale || 1;
    const rx = tileX || 0;
    const ry = tileY || 0;
    const isFlow = colorMode === "flow";
    const isMulti = colorMode === "rgb" && rgbMultiChannel;
    const isRelief = !!params.reliefOn && !isFlow && !isMulti;
    const reps = params.reliefEps != null ? params.reliefEps : 0.6;
    const fieldType = params.fieldType || "perlin";
    const isCells = fieldType === "cells" || fieldType === "borders" || fieldType === "smooth";
    const cellJitter = params.cellJitter != null ? params.cellJitter : 1;
    const eps = (params.flowEps ?? 0.6);
    // Layer 2 only composes into the standard render path — combining it
    // with the multi-sample flow stencil or per-channel RGB would 6× the
    // per-pixel cost and the visual win is unclear. Document the
    // restriction in the UI.
    const isLayer = !!layer2On && (layer2Mix || 0) > 0 && !isFlow && !isMulti;
    const l2sx = layer2ScaleX || scaleX;
    const l2sy = layer2ScaleY || scaleY;
    const l2oct = layer2Octaves || octaves;
    const l2blend = layer2BlendMode || "multiply";
    const lmix = layer2Mix || 0;
    const perm2 = isLayer ? makePermutation(layer2Base || 0) : null;

    const perm = makePermutation(base);
    const img = ctx.createImageData(width, height);
    const data = img.data;

    if (loop) {
      // Loop mode: (z, w) trace a circle in 4D noise. Modulo 1 ensures that
      // progress=1 is computed exactly like progress=0 —
      // Math.sin(2π) is otherwise ~ -2.4e-16 as a float, and Math.floor of
      // that pushes the hash to -1, masked to 255 → a completely different
      // image.
      const radius = (loopRadius || 1) * (scaleT || 1);
      const safeProgress = ((loopProgress || 0) % 1 + 1) % 1;
      const alpha = safeProgress * Math.PI * 2;
      const zc = Math.cos(alpha) * radius;
      const wc = Math.sin(alpha) * radius;
      const fld = isCells ? { kind: fieldType, jitter: cellJitter, turns: safeProgress } : null;
      const H = (x, y) => {
        const a = warpedFbm4(perm, x * scaleX, y * scaleY, zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
        if (!isLayer) return a;
        const b = warpedFbm4(perm2, x * l2sx, y * l2sy, zc, wc, l2oct, persistence, lacunarity, ws, wsc, rx, ry, fld);
        return a * (1 - lmix) + blendLayers(a, b, l2blend) * lmix;
      };
      for (let y = 0; y < height; y++) {
        for (let x = 0; x < width; x++) {
          let r, g, b;
          if (isFlow) {
            const n0 = warpedFbm4(perm, x * scaleX,         y * scaleY,         zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
            const n1 = warpedFbm4(perm, (x + eps) * scaleX, y * scaleY,         zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
            const n2 = warpedFbm4(perm, x * scaleX,         (y + eps) * scaleY, zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
            [r, g, b] = colorizeFlow(n1 - n0, n2 - n0, params);
          } else if (isMulti) {
            const nr = warpedFbm4(perm,  x * scaleX,         y * scaleY,        zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
            const ng = warpedFbm4(perm, (x * scaleX) +  53.0, y * scaleY,       zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
            const nb = warpedFbm4(perm,  x * scaleX,        (y * scaleY) + 71.0, zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
            [r, g, b] = colorizeRgbMulti(nr, ng, nb, params);
          } else if (isRelief) {
            const h0 = H(x, y), h1 = H(x + reps, y), h2 = H(x, y + reps);
            [r, g, b] = shadeRelief(colorize(h0, params), (h1 - h0) / (reps * scaleX), (h2 - h0) / (reps * scaleY), params);
          } else {
            const n1 = warpedFbm4(perm, x * scaleX, y * scaleY, zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
            let n = n1;
            if (isLayer) {
              const n2 = warpedFbm4(perm2, x * l2sx, y * l2sy, zc, wc, l2oct, persistence, lacunarity, ws, wsc, rx, ry, fld);
              n = n1 * (1 - lmix) + blendLayers(n1, n2, l2blend) * lmix;
            }
            [r, g, b] = colorize(n, params);
          }
          const i = (y * width + x) * 4;
          data[i] = r; data[i + 1] = g; data[i + 2] = b; data[i + 3] = 255;
        }
      }
    } else {
      const tz = (t || 0) * scaleT;
      const fld = isCells ? { kind: fieldType, jitter: cellJitter, turns: tz - Math.floor(tz) } : null;
      const H = (x, y) => {
        const a = warpedFbm3(perm, x * scaleX, y * scaleY, tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
        if (!isLayer) return a;
        const b = warpedFbm3(perm2, x * l2sx, y * l2sy, tz, l2oct, persistence, lacunarity, ws, wsc, rx, ry, fld);
        return a * (1 - lmix) + blendLayers(a, b, l2blend) * lmix;
      };
      for (let y = 0; y < height; y++) {
        for (let x = 0; x < width; x++) {
          let r, g, b;
          if (isFlow) {
            const n0 = warpedFbm3(perm, x * scaleX,         y * scaleY,         tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
            const n1 = warpedFbm3(perm, (x + eps) * scaleX, y * scaleY,         tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
            const n2 = warpedFbm3(perm, x * scaleX,         (y + eps) * scaleY, tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
            [r, g, b] = colorizeFlow(n1 - n0, n2 - n0, params);
          } else if (isMulti) {
            const nr = warpedFbm3(perm,  x * scaleX,         y * scaleY,        tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
            const ng = warpedFbm3(perm, (x * scaleX) +  53.0, y * scaleY,       tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
            const nb = warpedFbm3(perm,  x * scaleX,        (y * scaleY) + 71.0, tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
            [r, g, b] = colorizeRgbMulti(nr, ng, nb, params);
          } else if (isRelief) {
            const h0 = H(x, y), h1 = H(x + reps, y), h2 = H(x, y + reps);
            [r, g, b] = shadeRelief(colorize(h0, params), (h1 - h0) / (reps * scaleX), (h2 - h0) / (reps * scaleY), params);
          } else {
            const n1 = warpedFbm3(perm, x * scaleX, y * scaleY, tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
            let n = n1;
            if (isLayer) {
              const n2 = warpedFbm3(perm2, x * l2sx, y * l2sy, tz, l2oct, persistence, lacunarity, ws, wsc, rx, ry, fld);
              n = n1 * (1 - lmix) + blendLayers(n1, n2, l2blend) * lmix;
            }
            [r, g, b] = colorize(n, params);
          }
          const i = (y * width + x) * 4;
          data[i] = r; data[i + 1] = g; data[i + 2] = b; data[i + 3] = 255;
        }
      }
    }
    ctx.putImageData(img, 0, 0);
  }

  // ---------- WORKER SOURCE ----------
  // Loaded by the main thread as a Blob URL so the worker also runs under
  // file://. Self-contained copy of the core functions.
  const WORKER_SOURCE = `
    const BASE_PERM = ${JSON.stringify(BASE_PERM)};
    const GRAD4 = ${JSON.stringify(GRAD4)};
    ${makePermutation.toString()}
    ${fade.toString()}
    ${lerp.toString()}
    ${grad.toString()}
    ${grad4.toString()}
    ${wrapCell.toString()}
    ${nextCell.toString()}
    ${perlin3.toString()}
    ${perlin4.toString()}
    ${fbm3.toString()}
    ${fbm4.toString()}
    ${cellFbm.toString()}
    ${effectiveWarpScale.toString()}
    ${warpedFbm3.toString()}
    ${warpedFbm4.toString()}
    ${blendLayers.toString()}
    ${clamp01.toString()}
    ${gradientLookup.toString()}
    ${hsvToRgb.toString()}
    ${colorizeRgbMulti.toString()}
    ${colorizeFlow.toString()}
    ${colorize.toString()}
    ${shadeRelief.toString()}

    self.onmessage = function(e) {
      const { id, params } = e.data;
      const { width, height, t, scaleX, scaleY, scaleT,
              octaves, persistence, lacunarity, base,
              loop, loopProgress, loopRadius,
              warpStrength, warpScale,
              tileX, tileY,
              layer2On, layer2ScaleX, layer2ScaleY, layer2Octaves,
              layer2Base, layer2BlendMode, layer2Mix,
              colorMode, rgbMultiChannel } = params;
      const ws = warpStrength || 0;
      const wsc = warpScale || 1;
      const rx = tileX || 0;
      const ry = tileY || 0;
      const isFlow = colorMode === "flow";
      const isMulti = colorMode === "rgb" && rgbMultiChannel;
      const isRelief = !!params.reliefOn && !isFlow && !isMulti;
      const reps = params.reliefEps != null ? params.reliefEps : 0.6;
      const fieldType = params.fieldType || "perlin";
      const isCells = fieldType === "cells" || fieldType === "borders" || fieldType === "smooth";
      const cellJitter = params.cellJitter != null ? params.cellJitter : 1;
      const eps = params.flowEps != null ? params.flowEps : 0.6;
      const isLayer = !!layer2On && (layer2Mix || 0) > 0 && !isFlow && !isMulti;
      const l2sx = layer2ScaleX || scaleX;
      const l2sy = layer2ScaleY || scaleY;
      const l2oct = layer2Octaves || octaves;
      const l2blend = layer2BlendMode || "multiply";
      const lmix = layer2Mix || 0;
      const perm = makePermutation(base);
      const perm2 = isLayer ? makePermutation(layer2Base || 0) : null;
      const buf = new Uint8ClampedArray(width * height * 4);

      if (loop) {
        const radius = (loopRadius || 1) * (scaleT || 1);
        const safeProgress = ((loopProgress || 0) % 1 + 1) % 1;
        const alpha = safeProgress * Math.PI * 2;
        const zc = Math.cos(alpha) * radius;
        const wc = Math.sin(alpha) * radius;
        const fld = isCells ? { kind: fieldType, jitter: cellJitter, turns: safeProgress } : null;
        const H = (x, y) => {
          const a = warpedFbm4(perm, x * scaleX, y * scaleY, zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
          if (!isLayer) return a;
          const b = warpedFbm4(perm2, x * l2sx, y * l2sy, zc, wc, l2oct, persistence, lacunarity, ws, wsc, rx, ry, fld);
          return a * (1 - lmix) + blendLayers(a, b, l2blend) * lmix;
        };
        for (let y = 0; y < height; y++) {
          for (let x = 0; x < width; x++) {
            let c;
            if (isFlow) {
              const n0 = warpedFbm4(perm, x * scaleX,         y * scaleY,         zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
              const n1 = warpedFbm4(perm, (x + eps) * scaleX, y * scaleY,         zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
              const n2 = warpedFbm4(perm, x * scaleX,         (y + eps) * scaleY, zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
              c = colorizeFlow(n1 - n0, n2 - n0, params);
            } else if (isMulti) {
              const nr = warpedFbm4(perm,  x * scaleX,          y * scaleY,        zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
              const ng = warpedFbm4(perm, (x * scaleX) +  53.0, y * scaleY,        zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
              const nb = warpedFbm4(perm,  x * scaleX,         (y * scaleY) + 71.0, zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
              c = colorizeRgbMulti(nr, ng, nb, params);
            } else if (isRelief) {
              const h0 = H(x, y), h1 = H(x + reps, y), h2 = H(x, y + reps);
              c = shadeRelief(colorize(h0, params), (h1 - h0) / (reps * scaleX), (h2 - h0) / (reps * scaleY), params);
            } else {
              const n1 = warpedFbm4(perm, x * scaleX, y * scaleY, zc, wc, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
              let n = n1;
              if (isLayer) {
                const n2 = warpedFbm4(perm2, x * l2sx, y * l2sy, zc, wc, l2oct, persistence, lacunarity, ws, wsc, rx, ry, fld);
                n = n1 * (1 - lmix) + blendLayers(n1, n2, l2blend) * lmix;
              }
              c = colorize(n, params);
            }
            const i = (y * width + x) * 4;
            buf[i] = c[0]; buf[i+1] = c[1]; buf[i+2] = c[2]; buf[i+3] = 255;
          }
        }
      } else {
        const tz = (t || 0) * scaleT;
        const fld = isCells ? { kind: fieldType, jitter: cellJitter, turns: tz - Math.floor(tz) } : null;
        const H = (x, y) => {
          const a = warpedFbm3(perm, x * scaleX, y * scaleY, tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
          if (!isLayer) return a;
          const b = warpedFbm3(perm2, x * l2sx, y * l2sy, tz, l2oct, persistence, lacunarity, ws, wsc, rx, ry, fld);
          return a * (1 - lmix) + blendLayers(a, b, l2blend) * lmix;
        };
        for (let y = 0; y < height; y++) {
          for (let x = 0; x < width; x++) {
            let c;
            if (isFlow) {
              const n0 = warpedFbm3(perm, x * scaleX,         y * scaleY,         tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
              const n1 = warpedFbm3(perm, (x + eps) * scaleX, y * scaleY,         tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
              const n2 = warpedFbm3(perm, x * scaleX,         (y + eps) * scaleY, tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
              c = colorizeFlow(n1 - n0, n2 - n0, params);
            } else if (isMulti) {
              const nr = warpedFbm3(perm,  x * scaleX,          y * scaleY,        tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
              const ng = warpedFbm3(perm, (x * scaleX) +  53.0, y * scaleY,        tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
              const nb = warpedFbm3(perm,  x * scaleX,         (y * scaleY) + 71.0, tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
              c = colorizeRgbMulti(nr, ng, nb, params);
            } else if (isRelief) {
              const h0 = H(x, y), h1 = H(x + reps, y), h2 = H(x, y + reps);
              c = shadeRelief(colorize(h0, params), (h1 - h0) / (reps * scaleX), (h2 - h0) / (reps * scaleY), params);
            } else {
              const n1 = warpedFbm3(perm, x * scaleX, y * scaleY, tz, octaves, persistence, lacunarity, ws, wsc, rx, ry, fld);
              let n = n1;
              if (isLayer) {
                const n2 = warpedFbm3(perm2, x * l2sx, y * l2sy, tz, l2oct, persistence, lacunarity, ws, wsc, rx, ry, fld);
                n = n1 * (1 - lmix) + blendLayers(n1, n2, l2blend) * lmix;
              }
              c = colorize(n, params);
            }
            const i = (y * width + x) * 4;
            buf[i] = c[0]; buf[i+1] = c[1]; buf[i+2] = c[2]; buf[i+3] = 255;
          }
        }
      }

      // Transferable ArrayBuffer — zero-copy back to the main thread.
      self.postMessage({ id, width, height, buffer: buf.buffer }, [buf.buffer]);
    };
  `;

  window.MTPerlin = {
    makePermutation, perlin3, perlin4, fbm3, fbm4,
    warpedFbm3, warpedFbm4, cellFbm, blendLayers,
    generatePerlinImage, colorize, colorizeRgbMulti, colorizeFlow, shadeRelief,
    WORKER_SOURCE,
  };
})();