401 lines, 36 of them comments · 15,820 tokens, written in 269 seconds for $0.096


  1  /* ═══════════════════════════════════════════════════════════════
  2     C U R L   /   I N K
  3     ───────────────────────────────────────────────────────────────
  4     Thousands of particles ride a divergence-free curl-noise field.
  5     Instead of clearing, the canvas fades a whisper each frame, so
  6     the ink accumulates into silk-like streamlines that slowly
  7     dissolve and regrow.
  8  
  9     move mouse  → stir the field
 10     click/drag  → burst + ripple
 11     P  palette · C  clear · N  new field · S  save · H  hide UI
 12     ═══════════════════════════════════════════════════════════════ */
 13  
 14  const PALETTES = [
 15    { name: 'Nocturne', bg: [7, 10, 18], colors: [
 16        [64, 224, 208], [72, 145, 255], [168, 110, 255],
 17        [255, 94, 154], [255, 206, 120], [228, 238, 255]] },
 18    { name: 'Ember', bg: [14, 8, 6], colors: [
 19        [255, 87, 34], [255, 152, 0], [255, 213, 79],
 20        [244, 67, 54], [255, 236, 210], [150, 52, 36]] },
 21    { name: 'Verdigris', bg: [6, 14, 12], colors: [
 22        [46, 204, 150], [130, 230, 190], [22, 122, 112],
 23        [222, 202, 132], [198, 122, 72], [238, 250, 244]] },
 24    { name: 'Sakura', bg: [14, 8, 15], colors: [
 25        [255, 154, 180], [255, 206, 221], [188, 140, 255],
 26        [255, 240, 246], [255, 108, 132], [116, 74, 118]] },
 27    { name: 'Blueprint', bg: [7, 13, 26], colors: [
 28        [218, 234, 255], [128, 190, 255], [66, 118, 220],
 29        [255, 122, 110], [255, 255, 255], [42, 72, 132]] }
 30  ];
 31  
 32  let art;                 // offscreen ink buffer
 33  let vig;                 // vignette (multiply)
 34  let grains = [];         // film-grain frames (soft light)
 35  let parts = [];
 36  let rings = [];
 37  let sparks = [];
 38  
 39  let pi = 0, pal;
 40  let seedNum, seedLabel;
 41  let zoff = 0;
 42  let fieldScale = 0.0018;
 43  let baseFade = 15, fadeAmt = 15;
 44  let globalSpeed = 1;
 45  let pointerStrength = 0;
 46  let mvx = 0, mvy = 0, pmx = 0, pmy = 0;
 47  let hintA = 255, hintTimer = 420, showUI = true;
 48  let target = 900;
 49  let fv = { x: 0, y: 0 };   // scratch vector (avoids GC churn)
 50  
 51  const CYCLE = 6600;        // frames between "dissolve & regrow" events
 52  const DISSOLVE_LEN = 260;
 53  
 54  /* ─────────────────────────── setup ─────────────────────────── */
 55  
 56  function setup() {
 57    createCanvas(windowWidth, windowHeight);
 58    pixelDensity(1);
 59    colorMode(RGB, 255, 255, 255, 255);
 60    noiseDetail(4, 0.52);
 61  
 62    reseed(true);
 63    pal = PALETTES[pi];
 64  
 65    art = createGraphics(width, height);
 66    art.pixelDensity(1);
 67    buildBuffers();
 68    paintFull();
 69  
 70    target = countTarget();
 71    for (let i = 0; i < target; i++) parts.push(new Particle(true));
 72  
 73    pmx = mouseX; pmy = mouseY;
 74    textFont('monospace');
 75  }
 76  
 77  function reseed(labelToo) {
 78    seedNum = floor(random(999999));
 79    randomSeed(seedNum);
 80    noiseSeed(seedNum);
 81    seedLabel = nf(seedNum, 6);
 82    zoff = random(1000);
 83    if (labelToo) fieldScale = random(0.0013, 0.0026);
 84  }
 85  
 86  function countTarget() {
 87    return constrain(round((width * height) / 2000), 320, 1400);
 88  }
 89  
 90  function buildBuffers() {
 91    // --- vignette: radial falloff baked into a small buffer, upscaled
 92    vig = createGraphics(max(1, ceil(width / 3)), max(1, ceil(height / 3)));
 93    vig.pixelDensity(1);
 94    vig.loadPixels();
 95    const gw = vig.width, gh = vig.height;
 96    const cx = gw * 0.5, cy = gh * 0.5;
 97    const maxD = Math.sqrt(cx * cx + cy * cy);
 98    for (let y = 0; y < gh; y++) {
 99      for (let x = 0; x < gw; x++) {
100        const d = Math.sqrt((x - cx) * (x - cx) + (y - cy) * (y - cy)) / maxD;
101        const v = 255 * (1 - 0.74 * Math.pow(constrain(d, 0, 1), 2.5));
102        const i = (y * gw + x) * 4;
103        vig.pixels[i] = vig.pixels[i + 1] = vig.pixels[i + 2] = v;
104        vig.pixels[i + 3] = 255;
105      }
106    }
107    vig.updatePixels();
108  
109    // --- three grain frames at half res, cycled for a living texture
110    grains = [];
111    const gwid = max(1, ceil(width / 2)), ghei = max(1, ceil(height / 2));
112    for (let k = 0; k < 3; k++) {
113      const g = createGraphics(gwid, ghei);
114      g.pixelDensity(1);
115      g.loadPixels();
116      const p = g.pixels;
117      for (let i = 0; i < p.length; i += 4) {
118        const v = 128 + (Math.random() * 2 - 1) * 74;
119        p[i] = p[i + 1] = p[i + 2] = v;
120        p[i + 3] = 255;
121      }
122      g.updatePixels();
123      grains.push(g);
124    }
125  }
126  
127  function paintFull() {
128    art.noStroke();
129    art.fill(pal.bg[0], pal.bg[1], pal.bg[2], 255);
130    art.rect(0, 0, width, height);
131  }
132  
133  /* ─────────────────────── the flow field ─────────────────────── */
134  /* curl of a scalar noise potential → incompressible, swirl-only  */
135  
136  function field(x, y) {
137    const s = fieldScale, e = 1.6, z = zoff;
138    const n1 = noise(x * s, (y + e) * s, z);
139    const n2 = noise(x * s, (y - e) * s, z);
140    const n3 = noise((x + e) * s, y * s, z);
141    const n4 = noise((x - e) * s, y * s, z);
142  
143    let vx = (n1 - n2);
144    let vy = -(n3 - n4);
145    const m = Math.sqrt(vx * vx + vy * vy);
146    if (m > 1e-7) { vx /= m; vy /= m; } else { vx = 1; vy = 0; }
147  
148    // per-region energy so some zones rush and others crawl
149    const q = noise(x * s * 0.6 + 13.7, y * s * 0.6 - 7.3, z * 0.7);
150    const mag = 0.42 + q * 1.55;
151    vx *= mag; vy *= mag;
152  
153    // a very low-frequency counter-current keeps the composition alive
154    const a = noise(x * s * 0.22 + 91.1, y * s * 0.22 + 31.7, z * 0.4) * TAU * 2;
155    vx += Math.cos(a) * 0.34;
156    vy += Math.sin(a) * 0.34;
157  
158    fv.x = vx; fv.y = vy;
159    return fv;
160  }
161  
162  /* ───────────────────────── particle ───────────────────────── */
163  
164  class Particle {
165    constructor(staggered) { this.reset(staggered); }
166  
167    reset(staggered) {
168      this.x = random(width);
169      this.y = random(height);
170      this.px = this.x; this.py = this.y;
171      this.vx = 0; this.vy = 0;
172      this.maxLife = random(150, 640);
173      this.age = staggered ? random(this.maxLife) : 0;
174      this.w = pow(random(), 2.3) * 2.1 + 0.45;
175      this.spd = random(0.75, 2.35);
176      this.ci = pickColor();
177      this.ci2 = (this.ci + 1 + floor(random(pal.colors.length - 1))) % pal.colors.length;
178      this.spark = random() < 0.035;
179    }
180  
181    update() {
182      const v = field(this.x, this.y);
183      const s = this.spd * globalSpeed;
184      this.vx = lerp(this.vx, v.x * s, 0.13);
185      this.vy = lerp(this.vy, v.y * s, 0.13);
186  
187      if (pointerStrength > 0.01) {
188        const dx = this.x - mouseX, dy = this.y - mouseY;
189        const d2 = dx * dx + dy * dy;
190        const R = 210;
191        if (d2 < R * R) {
192          const d = Math.sqrt(d2) + 0.001;
193          const f = 1 - d / R;
194          const swirl = pointerStrength * f * f * 11;
195          this.vx += (-dy / d) * swirl + (dx / d) * swirl * 0.22 + mvx * f * 0.55;
196          this.vy += (dx / d) * swirl + (dy / d) * swirl * 0.22 + mvy * f * 0.55;
197        }
198      }
199  
200      this.px = this.x; this.py = this.y;
201      this.x += this.vx; this.y += this.vy;
202      this.age++;
203  
204      if (this.age > this.maxLife ||
205          this.x < -50 || this.x > width + 50 ||
206          this.y < -50 || this.y > height + 50) this.reset(false);
207    }
208  
209    display() {
210      const t = constrain(this.age / this.maxLife, 0, 1);
211      const env = Math.sin(PI * t);                      // fade in / fade out
212      const sp = Math.sqrt(this.vx * this.vx + this.vy * this.vy);
213      const c1 = pal.colors[this.ci], c2 = pal.colors[this.ci2];
214      const r = c1[0] + (c2[0] - c1[0]) * t;
215      const g = c1[1] + (c2[1] - c1[1]) * t;
216      const b = c1[2] + (c2[2] - c1[2]) * t;
217  
218      const a = env * (this.spark ? 130 : 44) * (0.5 + 0.5 * constrain(sp / 2.4, 0, 1));
219      art.stroke(r, g, b, a);
220      art.strokeWeight(this.w * (0.55 + 0.7 * env));
221      art.line(this.px, this.py, this.x, this.y);
222  
223      if (this.spark && env > 0.55 && random() < 0.09) {
224        sparks.push(this.x, this.y, r, g, b, this.w * 1.7 * env);
225      }
226    }
227  }
228  
229  function pickColor() {
230    // bias toward the palette's leading hues for coherence
231    const r = random();
232    if (r < 0.30) return 0;
233    if (r < 0.55) return 1;
234    if (r < 0.72) return 2;
235    if (r < 0.84) return 3;
236    if (r < 0.94) return 4;
237    return 5 % pal.colors.length;
238  }
239  
240  /* ───────────────────────── ripples ───────────────────────── */
241  
242  class Ring {
243    constructor(x, y, c) {
244      this.x = x; this.y = y; this.r = 3;
245      this.max = random(130, 330);
246      this.c = c; this.life = 1;
247    }
248    update() { this.r += (this.max - this.r) * 0.055 + 1.1; this.life *= 0.957; }
249    display() {
250      art.noFill();
251      art.stroke(this.c[0], this.c[1], this.c[2], 95 * this.life);
252      art.strokeWeight(1.3 * this.life + 0.2);
253      art.ellipse(this.x, this.y, this.r * 2, this.r * 2);
254    }
255    dead() { return this.life < 0.02; }
256  }
257  
258  function burst(x, y, n) {
259    const c = random(pal.colors);
260    rings.push(new Ring(x, y, c));
261    if (random() < 0.5) rings.push(new Ring(x, y, random(pal.colors)));
262    for (let i = 0; i < n; i++) {
263      const p = parts.length < target + 400 ? new Particle(false) : parts[floor(random(parts.length))];
264      if (parts.indexOf(p) === -1) parts.push(p);
265      const a = random(TAU), d = pow(random(), 0.6) * 70;
266      p.x = x + Math.cos(a) * d;
267      p.y = y + Math.sin(a) * d;
268      p.px = p.x; p.py = p.y;
269      p.vx = Math.cos(a) * random(1.5, 6);
270      p.vy = Math.sin(a) * random(1.5, 6);
271      p.age = 0;
272      p.maxLife = random(120, 420);
273      p.ci = pickColor();
274      p.ci2 = (p.ci + 1 + floor(random(pal.colors.length - 1))) % pal.colors.length;
275    }
276  }
277  
278  /* ─────────────────────────── draw ─────────────────────────── */
279  
280  function draw() {
281    // pointer velocity + decaying influence
282    mvx = mouseX - pmx; mvy = mouseY - pmy;
283    const ms = Math.sqrt(mvx * mvx + mvy * mvy);
284    pointerStrength = Math.min(1, pointerStrength * 0.90 + constrain(ms / 26, 0, 1) * 0.35);
285    if (mouseIsPressed) pointerStrength = Math.min(1.4, pointerStrength + 0.12);
286    pmx = mouseX; pmy = mouseY;
287  
288    // breathing tempo
289    globalSpeed = 1 + 0.28 * Math.sin(frameCount * 0.0031) + 0.12 * Math.sin(frameCount * 0.00077);
290    zoff += 0.00115;
291  
292    // long-form structure: slowly dissolve the page, then let it regrow
293    const cyc = frameCount % CYCLE;
294    if (cyc > CYCLE - DISSOLVE_LEN) {
295      const k = (cyc - (CYCLE - DISSOLVE_LEN)) / DISSOLVE_LEN;
296      fadeAmt = lerp(baseFade, 34, Math.sin(k * PI * 0.5));
297    } else {
298      fadeAmt = baseFade;
299    }
300    if (cyc === 0 && frameCount > 1) {
301      reseed(false);
302      for (let i = 0; i < parts.length * 0.45; i++) parts[floor(random(parts.length))].reset(true);
303    }
304  
305    /* --- ink layer --- */
306    art.blendMode(BLEND);
307    art.noStroke();
308    art.fill(pal.bg[0], pal.bg[1], pal.bg[2], fadeAmt);
309    art.rect(0, 0, width, height);
310  
311    for (let i = 0; i < parts.length; i++) { parts[i].update(); parts[i].display(); }
312  
313    for (let i = rings.length - 1; i >= 0; i--) {
314      rings[i].update(); rings[i].display();
315      if (rings[i].dead()) rings.splice(i, 1);
316    }
317  
318    // additive sparkle pass
319    if (sparks.length) {
320      art.blendMode(ADD);
321      art.noStroke();
322      for (let i = 0; i < sparks.length; i += 6) {
323        art.fill(sparks[i + 2], sparks[i + 3], sparks[i + 4], 150);
324        art.circle(sparks[i], sparks[i + 1], sparks[i + 5]);
325      }
326      art.blendMode(BLEND);
327      sparks.length = 0;
328    }
329  
330    /* --- composite: ink → vignette → grain --- */
331    blendMode(BLEND);
332    image(art, 0, 0);
333  
334    blendMode(MULTIPLY);
335    image(vig, 0, 0, width, height);
336  
337    blendMode(SOFT_LIGHT);
338    tint(255, 42);
339    image(grains[frameCount % grains.length], 0, 0, width, height);
340    noTint();
341  
342    blendMode(BLEND);
343    drawUI();
344  }
345  
346  function drawUI() {
347    if (!showUI) return;
348    if (hintTimer > 0) { hintTimer--; hintA = min(255, hintA + 14); }
349    else hintA = max(0, hintA - 6);
350    if (hintA <= 0) return;
351  
352    const a = hintA * 0.62;
353    noStroke();
354    textLeading(15);
355    textSize(11);
356    textAlign(LEFT, BOTTOM);
357    fill(255, 255, 255, a * 0.9);
358    text('CURL / INK', 22, height - 62);
359    fill(255, 255, 255, a * 0.5);
360    text('seed ' + seedLabel + '   ·   ' + pal.name.toUpperCase(), 22, height - 46);
361    text('move: stir   click: burst   [P]alette [C]lear [N]ew [S]ave [H]ide', 22, height - 30);
362  
363    textAlign(RIGHT, BOTTOM);
364    fill(255, 255, 255, a * 0.35);
365    text(nf(parts.length, 4) + ' strokes  ·  ' + nf(frameRate(), 2, 0) + ' fps', width - 22, height - 30);
366  }
367  
368  /* ───────────────────────── interaction ───────────────────────── */
369  
370  function mousePressed() { burst(mouseX, mouseY, 70); hintTimer = 200; }
371  function mouseDragged() { if (frameCount % 3 === 0) burst(mouseX, mouseY, 6); }
372  function touchStarted() { burst(mouseX, mouseY, 70); hintTimer = 200; return false; }
373  
374  function keyPressed() {
375    hintTimer = 260;
376    const k = key.toLowerCase();
377    if (k === 'p') { pi = (pi + 1) % PALETTES.length; pal = PALETTES[pi]; }
378    else if (k === 'c') { paintFull(); }
379    else if (k === 'n') {
380      reseed(true);
381      for (const p of parts) p.reset(true);
382      rings.length = 0;
383    } else if (k === 's') { saveCanvas('curl-ink-' + seedLabel, 'png'); }
384    else if (k === 'h') { showUI = !showUI; }
385    else if (k === ' ') { for (const p of parts) p.reset(true); }
386  }
387  
388  function windowResized() {
389    const nw = windowWidth, nh = windowHeight;
390    const sx = nw / width, sy = nh / height;
391    resizeCanvas(nw, nh);
392    art.resizeCanvas(nw, nh);
393    for (const p of parts) { p.x *= sx; p.y *= sy; p.px = p.x; p.py = p.y; }
394    for (const r of rings) { r.x *= sx; r.y *= sy; }
395    buildBuffers();
396    paintFull();
397    target = countTarget();
398    while (parts.length < target) parts.push(new Particle(true));
399    while (parts.length > target) parts.pop();
400  }
401