533 lines, 28 of them comments · 15,963 tokens, written in 557 seconds for $0.096


  1  /* ============================================================
  2     SILK CURRENTS
  3     A curl-noise light loom.
  4     - particles are advected by a divergence-free (curl) field
  5     - sampled from a bilinear grid for speed
  6     - luminous "cores" orbit on lissajous paths and knot the flow
  7     - pointer = vortex, click = bloom, R = reseed, S = save
  8     ============================================================ */
  9  
 10  let parts = [];
 11  let sparks = [];
 12  let rings = [];
 13  let cores = [];
 14  
 15  let time = 0;      // field evolution clock
 16  let orbit = 0;     // core motion clock
 17  let hueNow = 205;
 18  let hueTarget = 205;
 19  let swirlDir = 1;
 20  let paused = false;
 21  
 22  let pointer = null;
 23  let pointerHeat = 0;
 24  
 25  const NOISE_SCALE = 0.0015;
 26  const EPS = 0.40;
 27  
 28  // flow grid
 29  let CELL = 26;
 30  let gcols, grows, gvx, gvy, gm;
 31  const _s = [0, 0, 0];
 32  
 33  /* ------------------------------ setup ------------------------------ */
 34  
 35  function setup() {
 36    createCanvas(windowWidth, windowHeight);
 37    pixelDensity(1);
 38    colorMode(HSB, 360, 100, 100, 100);
 39    noiseDetail(4, 0.5);
 40    strokeCap(ROUND);
 41    strokeJoin(ROUND);
 42    cursor(CROSS);
 43  
 44    seedField();
 45    updateGrid();
 46    paint(true);
 47  
 48    // a couple of opening blooms so the canvas starts alive
 49    for (let i = 0; i < 3; i++) {
 50      const c = random(cores);
 51      const p = c.pos();
 52      bloom(p.x, p.y, 0.7, false);
 53    }
 54  }
 55  
 56  function seedField() {
 57    cores = [];
 58    const nc = 3 + floor(random(3));
 59    for (let i = 0; i < nc; i++) cores.push(new Core());
 60  
 61    parts = [];
 62    const target = constrain(floor((width * height) / 520), 900, 5200);
 63    for (let i = 0; i < target; i++) parts.push(new Particle(true));
 64  
 65    sparks = [];
 66    rings = [];
 67    buildGrid();
 68  }
 69  
 70  function buildGrid() {
 71    gcols = Math.ceil(width / CELL) + 3;
 72    grows = Math.ceil(height / CELL) + 3;
 73    gvx = new Float32Array(gcols * grows);
 74    gvy = new Float32Array(gcols * grows);
 75    gm = new Float32Array(gcols * grows);
 76  }
 77  
 78  /* --------------------------- flow field ---------------------------- */
 79  
 80  function updateGrid() {
 81    let i = 0;
 82    for (let j = 0; j < grows; j++) {
 83      const ny = (j - 1) * CELL * NOISE_SCALE;
 84      for (let k = 0; k < gcols; k++) {
 85        const nx = (k - 1) * CELL * NOISE_SCALE;
 86        const n1 = noise(nx, ny + EPS, time);
 87        const n2 = noise(nx, ny - EPS, time);
 88        const n3 = noise(nx + EPS, ny, time);
 89        const n4 = noise(nx - EPS, ny, time);
 90        let vx = n1 - n2;
 91        let vy = -(n3 - n4);
 92        const m = Math.hypot(vx, vy);
 93        if (m > 1e-6) {
 94          const sp = constrain(map(m, 0, 0.34, 0.35, 2.5), 0.28, 2.7);
 95          gvx[i] = (vx / m) * sp;
 96          gvy[i] = (vy / m) * sp;
 97          gm[i] = sp;
 98        } else {
 99          gvx[i] = 0; gvy[i] = 0; gm[i] = 0;
100        }
101        i++;
102      }
103    }
104  }
105  
106  function sampleGrid(x, y, out) {
107    const gx = x / CELL + 1;
108    const gy = y / CELL + 1;
109    let x0 = Math.floor(gx), y0 = Math.floor(gy);
110    const fx = gx - x0, fy = gy - y0;
111    x0 = constrain(x0, 0, gcols - 2);
112    y0 = constrain(y0, 0, grows - 2);
113    const x1 = x0 + 1, y1 = y0 + 1;
114    const i00 = y0 * gcols + x0;
115    const i10 = y0 * gcols + x1;
116    const i01 = y1 * gcols + x0;
117    const i11 = y1 * gcols + x1;
118    const w0 = 1 - fx, w1 = fx;
119    const u0 = 1 - fy, u1 = fy;
120    out[0] = (gvx[i00] * w0 + gvx[i10] * w1) * u0 + (gvx[i01] * w0 + gvx[i11] * w1) * u1;
121    out[1] = (gvy[i00] * w0 + gvy[i10] * w1) * u0 + (gvy[i01] * w0 + gvy[i11] * w1) * u1;
122    out[2] = (gm[i00] * w0 + gm[i10] * w1) * u0 + (gm[i01] * w0 + gm[i11] * w1) * u1;
123  }
124  
125  /* ------------------------------ cores ------------------------------ */
126  
127  class Core {
128    constructor() {
129      this.rx = random(0.16, 0.36);
130      this.ry = random(0.14, 0.34);
131      this.sx = random(0.22, 0.62) * (random() < 0.5 ? -1 : 1);
132      this.sy = random(0.22, 0.62) * (random() < 0.5 ? -1 : 1);
133      this.ph = random(TWO_PI);
134      this.hueOff = random(-46, 70);
135      this.r = random(90, 190);
136      this.pulse = random(0.6, 1.5);
137      this.dir = random() < 0.5 ? -1 : 1;
138    }
139    pos() {
140      return {
141        x: width * 0.5 + cos(orbit * this.sx + this.ph) * width * this.rx,
142        y: height * 0.5 + sin(orbit * this.sy + this.ph * 1.7) * height * this.ry
143      };
144    }
145    radius() {
146      return this.r * (1 + 0.22 * sin(orbit * this.pulse * 2 + this.ph));
147    }
148  }
149  
150  /* ---------------------------- particles ---------------------------- */
151  
152  class Particle {
153    constructor(scatter) {
154      this.depth = random();
155      this.w = map(this.depth, 0, 1, 0.45, 1.8);
156      this.sp = map(this.depth, 0, 1, 0.42, 1.7);
157      this.hueOff = random(-24, 24);
158      this.mag = 1;
159      this.reset(scatter);
160    }
161    reset(scatter) {
162      if (scatter || cores.length === 0 || random() < 0.4) {
163        this.x = random(width);
164        this.y = random(height);
165      } else {
166        const c = random(cores);
167        const p = c.pos();
168        const a = random(TWO_PI);
169        const r = random(c.radius() * 0.15, c.radius());
170        this.x = p.x + cos(a) * r;
171        this.y = p.y + sin(a) * r;
172      }
173      this.px = this.x;
174      this.py = this.y;
175      this.life = random(150, 480) * (0.55 + this.depth * 0.9);
176      this.age = random(0, this.life * 0.3);
177    }
178    step(dt) {
179      this.px = this.x;
180      this.py = this.y;
181  
182      sampleGrid(this.x, this.y, _s);
183      let vx = _s[0] * this.sp;
184      let vy = _s[1] * this.sp;
185      this.mag = _s[2];
186  
187      // cores: tangential swirl + gentle inward draw
188      for (let i = 0; i < cores.length; i++) {
189        const c = cores[i];
190        const p = c.pos();
191        const ox = this.x - p.x;
192        const oy = this.y - p.y;
193        const d2 = ox * ox + oy * oy;
194        const R = c.radius();
195        if (d2 < R * R) {
196          const d = sqrt(d2) + 0.001;
197          const f = 1 - d / R;
198          const s = f * f * 2.5 * c.dir;
199          vx += (-oy / d) * s + (-ox / d) * s * 0.32;
200          vy += (ox / d) * s + (-oy / d) * s * 0.32;
201        }
202      }
203  
204      // pointer vortex
205      if (pointer && pointerHeat > 0.01) {
206        const ox = this.x - pointer.x;
207        const oy = this.y - pointer.y;
208        const d = Math.hypot(ox, oy) + 0.001;
209        const R = 280;
210        if (d < R) {
211          const f = 1 - d / R;
212          const s = f * f * 6.5 * pointerHeat;
213          vx += (-oy / d) * s * swirlDir + (ox / d) * s * 0.22;
214          vy += (ox / d) * s * swirlDir + (oy / d) * s * 0.22;
215        }
216      }
217  
218      this.x += vx * dt;
219      this.y += vy * dt;
220      this.age += dt;
221  
222      if (this.x < -40 || this.x > width + 40 ||
223          this.y < -40 || this.y > height + 40 ||
224          this.age > this.life) {
225        this.reset(false);
226      }
227    }
228    draw() {
229      const t = this.age / this.life;
230      const fade = sin(t * PI);
231      if (fade <= 0.002) return;
232      const ang = atan2(this.y - this.py, this.x - this.px);
233      const h = (hueNow + degrees(ang) * 0.30 + this.mag * 26 + this.hueOff + 720) % 360;
234      const a = fade * map(this.depth, 0, 1, 4.5, 17);
235      stroke(h, constrain(90 - this.mag * 9, 38, 92), 100, a);
236      strokeWeight(this.w);
237      line(this.px, this.py, this.x, this.y);
238  
239      // rare sparkle at the head of a thread
240      if (this.depth > 0.86 && fade > 0.5 && random() < 0.006) {
241        noStroke();
242        fill((h + 30) % 360, 25, 100, fade * 35);
243        circle(this.x, this.y, this.w * 2.6);
244      }
245    }
246  }
247  
248  /* ------------------------- sparks & rings -------------------------- */
249  
250  class Spark {
251    constructor(x, y, vx, vy) {
252      this.x = x; this.y = y;
253      this.px = x; this.py = y;
254      this.vx = vx; this.vy = vy;
255      this.life = random(26, 78);
256      this.age = 0;
257      this.h = random(-32, 34);
258      this.w = random(0.7, 2.4);
259    }
260    step(dt) {
261      this.px = this.x; this.py = this.y;
262      sampleGrid(this.x, this.y, _s);
263      this.vx += _s[0] * 0.09 * dt;
264      this.vy += _s[1] * 0.09 * dt;
265      this.vx *= 0.962; this.vy *= 0.962;
266      this.x += this.vx * dt;
267      this.y += this.vy * dt;
268      this.age += dt;
269    }
270    dead() { return this.age > this.life; }
271    draw() {
272      const f = 1 - this.age / this.life;
273      stroke((hueNow + this.h + 360) % 360, 48, 100, f * f * 78);
274      strokeWeight(this.w * f + 0.25);
275      line(this.px, this.py, this.x, this.y);
276    }
277  }
278  
279  class Ring {
280    constructor(x, y, r0, r1) {
281      this.x = x; this.y = y;
282      this.r0 = r0; this.r1 = r1;
283      this.r = r0;
284      this.life = random(55, 100);
285      this.age = 0;
286      this.h = random(-28, 30);
287    }
288    step(dt) {
289      this.age += dt;
290      const t = constrain(this.age / this.life, 0, 1);
291      this.r = lerp(this.r0, this.r1, 1 - pow(1 - t, 3));
292    }
293    dead() { return this.age > this.life; }
294    draw() {
295      const t = constrain(this.age / this.life, 0, 1);
296      const a = pow(1 - t, 2.2) * 42;
297      if (a < 0.4) return;
298      noFill();
299      stroke((hueNow + this.h + 360) % 360, 62, 100, a);
300      strokeWeight(map(t, 0, 1, 2.8, 0.25));
301      ellipse(this.x, this.y, this.r * 2, this.r * 2);
302    }
303  }
304  
305  /* ------------------------------ bloom ------------------------------ */
306  
307  function bloom(x, y, power, shiftHue) {
308    if (shiftHue !== false) {
309      hueTarget = (hueTarget + 34 + random(-12, 12) + 360) % 360;
310      swirlDir *= -1;
311    }
312    const n = floor(46 * power);
313    for (let i = 0; i < n; i++) {
314      const a = random(TWO_PI);
315      const sp = random(0.5, 7.5) * power;
316      sparks.push(new Spark(
317        x + cos(a) * random(0, 16),
318        y + sin(a) * random(0, 16),
319        cos(a) * sp, sin(a) * sp
320      ));
321    }
322    const nr = 2 + floor(random(2));
323    for (let i = 0; i < nr; i++) {
324      rings.push(new Ring(x, y, random(8, 40), random(180, 380) * power));
325    }
326    // re-seed a handful of threads right at the bloom so silk erupts
327    const reseed = floor(50 * power);
328    for (let i = 0; i < reseed && i < parts.length; i++) {
329      const p = parts[floor(random(parts.length))];
330      const a = random(TWO_PI);
331      const r = random(0, 34);
332      p.x = x + cos(a) * r;
333      p.y = y + sin(a) * r;
334      p.px = p.x; p.py = p.y;
335      p.age = 0;
336    }
337    if (sparks.length > 1400) sparks.splice(0, sparks.length - 1400);
338    if (rings.length > 60) rings.splice(0, rings.length - 60);
339  }
340  
341  /* --------------------------- background ---------------------------- */
342  
343  function rgba(c) {
344    return 'rgba(' + c.levels[0] + ',' + c.levels[1] + ',' + c.levels[2] + ',' +
345           (c.levels[3] / 255).toFixed(3) + ')';
346  }
347  
348  function paint(hard) {
349    blendMode(BLEND);
350    noStroke();
351    const ctx = drawingContext;
352    const cx = width * 0.5, cy = height * 0.52;
353    const R = Math.hypot(width, height) * 0.66;
354    const a = hard ? 255 : 14;
355    const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, R);
356    const inner = color((hueNow + 205) % 360, 58, hard ? 10 : 7, a);
357    const outer = color((hueNow + 238) % 360, 68, hard ? 3 : 2, a);
358    g.addColorStop(0, rgba(inner));
359    g.addColorStop(1, rgba(outer));
360    ctx.fillStyle = g;
361    ctx.fillRect(0, 0, width, height);
362    if (hard) vignette(1);
363  }
364  
365  function vignette(a) {
366    const ctx = drawingContext;
367    const g = ctx.createRadialGradient(
368      width * 0.5, height * 0.5, min(width, height) * 0.42,
369      width * 0.5, height * 0.5, Math.hypot(width, height) * 0.66
370    );
371    g.addColorStop(0, 'rgba(0,0,0,0)');
372    g.addColorStop(1, 'rgba(0,0,0,' + a.toFixed(3) + ')');
373    ctx.fillStyle = g;
374    ctx.fillRect(0, 0, width, height);
375  }
376  
377  function drawCoreGlow() {
378    const ctx = drawingContext;
379    for (let i = 0; i < cores.length; i++) {
380      const c = cores[i];
381      const p = c.pos();
382      const R = c.radius() * 1.7;
383      const col = color((hueNow + c.hueOff + 360) % 360, 74, 100, 100);
384      const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, R);
385      g.addColorStop(0, 'rgba(' + col.levels[0] + ',' + col.levels[1] + ',' + col.levels[2] + ',0.085)');
386      g.addColorStop(0.4, 'rgba(' + col.levels[0] + ',' + col.levels[1] + ',' + col.levels[2] + ',0.028)');
387      g.addColorStop(1, 'rgba(' + col.levels[0] + ',' + col.levels[1] + ',' + col.levels[2] + ',0)');
388      ctx.fillStyle = g;
389      ctx.beginPath();
390      ctx.arc(p.x, p.y, R, 0, TWO_PI);
391      ctx.fill();
392  
393      // tiny molten heart
394      noStroke();
395      fill((hueNow + c.hueOff + 360) % 360, 30, 100, 16);
396      circle(p.x, p.y, 5 + 3 * sin(orbit * 3 + c.ph));
397    }
398  }
399  
400  /* ------------------------------- draw ------------------------------ */
401  
402  function draw() {
403    const dt = constrain(deltaTime / 16.6667, 0.4, 2.6);
404  
405    if (!paused) {
406      time += 0.0013 * dt;
407      orbit += 0.0042 * dt;
408      hueNow = lerpAngle(hueNow, hueTarget, 0.012 * dt);
409      pointerHeat *= pow(0.955, dt);
410    }
411  
412    paint(false);
413    if (!paused) vignette(0.012 * dt);
414  
415    updateGrid();
416  
417    blendMode(ADD);
418    drawCoreGlow();
419  
420    if (!paused) {
421      for (let i = 0; i < parts.length; i++) parts[i].step(dt);
422      for (let i = sparks.length - 1; i >= 0; i--) {
423        sparks[i].step(dt);
424        if (sparks[i].dead()) sparks.splice(i, 1);
425      }
426      for (let i = rings.length - 1; i >= 0; i--) {
427        rings[i].step(dt);
428        if (rings[i].dead()) rings.splice(i, 1);
429      }
430    }
431  
432    for (let i = 0; i < parts.length; i++) parts[i].draw();
433    for (let i = 0; i < sparks.length; i++) sparks[i].draw();
434    for (let i = 0; i < rings.length; i++) rings[i].draw();
435  
436    // pointer halo
437    if (pointer && pointerHeat > 0.02) {
438      noStroke();
439      fill((hueNow + 40) % 360, 55, 100, 10 * pointerHeat);
440      circle(pointer.x, pointer.y, 26 + 90 * pointerHeat);
441    }
442  
443    blendMode(BLEND);
444    drawCaption();
445  
446    // ambient bloom from a random core
447    if (!paused && frameCount % 220 === 0 && cores.length) {
448      const c = random(cores);
449      const p = c.pos();
450      bloom(p.x, p.y, 0.5, false);
451    }
452  }
453  
454  function lerpAngle(a, b, t) {
455    let d = ((b - a + 540) % 360) - 180;
456    return (a + d * t + 360) % 360;
457  }
458  
459  function drawCaption() {
460    const hint = constrain(map(frameCount, 90, 720, 70, 0), 0, 70);
461    noStroke();
462    textAlign(LEFT, TOP);
463    textFont('Georgia, serif');
464    textStyle(ITALIC);
465    textSize(15);
466    fill(45, 12, 100, 26);
467    text('silk currents', 26, 24);
468    if (hint > 0.5) {
469      textStyle(NORMAL);
470      textSize(11);
471      fill(45, 8, 100, hint);
472      text('move to stir the field  ·  click to bloom  ·  R reseed  ·  SPACE pause  ·  S save', 26, 48);
473    }
474    textStyle(NORMAL);
475  }
476  
477  /* --------------------------- interaction --------------------------- */
478  
479  function setPointer(x, y) {
480    pointer = { x: x, y: y };
481    pointerHeat = min(1.35, pointerHeat + 0.45);
482  }
483  
484  function mouseMoved() { setPointer(mouseX, mouseY); }
485  function mouseDragged() { setPointer(mouseX, mouseY); pointerHeat = min(1.5, pointerHeat + 0.3); }
486  
487  function mousePressed() {
488    bloom(mouseX, mouseY, 1.15, true);
489    return false;
490  }
491  
492  function touchMoved() {
493    if (touches.length) setPointer(touches[0].x, touches[0].y);
494    return false;
495  }
496  function touchStarted() {
497    if (touches.length) bloom(touches[0].x, touches[0].y, 1.15, true);
498    return false;
499  }
500  
501  function keyPressed() {
502    const k = key.toLowerCase();
503    if (k === 'r') {
504      hueTarget = random(360);
505      seedField();
506      updateGrid();
507      paint(true);
508      for (let i = 0; i < 3; i++) {
509        const c = random(cores);
510        const p = c.pos();
511        bloom(p.x, p.y, 0.8, false);
512      }
513    } else if (k === 's') {
514      saveCanvas('silk-currents', 'png');
515    } else if (k === ' ') {
516      paused = !paused;
517    } else if (k === 'c') {
518      hueTarget = (hueTarget + 60) % 360;
519    }
520  }
521  
522  function windowResized() {
523    resizeCanvas(windowWidth, windowHeight);
524    buildGrid();
525    updateGrid();
526    paint(true);
527    for (let i = 0; i < parts.length; i++) {
528      parts[i].x = constrain(parts[i].x, 0, width);
529      parts[i].y = constrain(parts[i].y, 0, height);
530      parts[i].px = parts[i].x;
531      parts[i].py = parts[i].y;
532    }
533  }