399 lines, 27 of them comments · 13,640 tokens, written in 461 seconds for $0.082


  1  /* ============================================================
  2     CURL BLOOM
  3     A flow-field ink study. ~1300 motes drift through a
  4     two-octave Perlin field, painting additive light into a
  5     persistent buffer that slowly decays toward the palette's
  6     ground colour. Composited with a bloom pass, grain, vignette.
  7     ============================================================ */
  8  
  9  const PALETTES = [
 10    { name: 'bioluminescence', bg: '#060812', stops: ['#00e5ff', '#3dffb0', '#b06cff', '#ff5ec8'] },
 11    { name: 'ember tide',      bg: '#120608', stops: ['#ff3b1f', '#ff9c1a', '#ffe14d', '#ff2f6e'] },
 12    { name: 'verdigris',       bg: '#04100f', stops: ['#38e0a8', '#2fd8e8', '#a9e86b', '#3aa8ff'] },
 13    { name: 'ultraviolet',     bg: '#08061a', stops: ['#6a3cff', '#c43cff', '#2f7dff', '#ff4fd8'] },
 14    { name: 'bone & gold',     bg: '#0d0b09', stops: ['#ffd27a', '#ffb15c', '#fff6e8', '#ff7a45'] }
 15  ];
 16  
 17  const NUM_MOTES = 1300;
 18  const LUT_SIZE  = 96;
 19  
 20  let trail, glow, grainBuf, vignetteBuf;
 21  let motes = [];
 22  let waves = [];
 23  let sparkles = [];
 24  
 25  let LUT = [];
 26  let BG = [0, 0, 0];
 27  let palIndex = 0;
 28  
 29  let t = 0;
 30  let flash = 0;
 31  let uiAlpha = 255;
 32  let uiTimer = 0;
 33  
 34  let pointer = { x: -999, y: -999, px: -999, py: -999, vx: 0, vy: 0, inside: false };
 35  
 36  /* ---------------------------------------------------------- */
 37  
 38  function setup() {
 39    createCanvas(windowWidth, windowHeight);
 40    pixelDensity(1);
 41    colorMode(RGB, 255, 255, 255, 255);
 42    noiseSeed(floor(random(999999)));
 43  
 44    buildBuffers();
 45    loadPalette(0);
 46  
 47    for (let i = 0; i < NUM_MOTES; i++) motes.push(new Mote());
 48    for (let i = 0; i < 70; i++) {
 49      sparkles.push({
 50        x: random(width), y: random(height),
 51        r: random(0.6, 1.7), ph: random(TAU), sp: random(0.01, 0.035)
 52      });
 53    }
 54  
 55    trail.background(BG[0], BG[1], BG[2]);
 56    background(BG[0], BG[1], BG[2]);
 57  }
 58  
 59  function buildBuffers() {
 60    trail = createGraphics(width, height);
 61    trail.pixelDensity(1);
 62    trail.colorMode(RGB, 255, 255, 255, 255);
 63  
 64    glow = createGraphics(max(1, ceil(width / 2)), max(1, ceil(height / 2)));
 65    glow.pixelDensity(1);
 66  
 67    // Static film grain, drawn jittered each frame.
 68    grainBuf = createGraphics(width + 24, height + 24);
 69    grainBuf.pixelDensity(1);
 70    grainBuf.loadPixels();
 71    for (let i = 0; i < grainBuf.pixels.length; i += 4) {
 72      const v = random(84, 172);
 73      grainBuf.pixels[i] = v;
 74      grainBuf.pixels[i + 1] = v;
 75      grainBuf.pixels[i + 2] = v;
 76      grainBuf.pixels[i + 3] = 255;
 77    }
 78    grainBuf.updatePixels();
 79  
 80    // Radial vignette.
 81    vignetteBuf = createGraphics(width, height);
 82    vignetteBuf.pixelDensity(1);
 83    const c = vignetteBuf.drawingContext;
 84    const g = c.createRadialGradient(
 85      width / 2, height / 2, min(width, height) * 0.18,
 86      width / 2, height / 2, max(width, height) * 0.78
 87    );
 88    g.addColorStop(0, 'rgba(0,0,0,0)');
 89    g.addColorStop(0.65, 'rgba(0,0,0,0.30)');
 90    g.addColorStop(1, 'rgba(0,0,0,0.82)');
 91    c.fillStyle = g;
 92    c.fillRect(0, 0, width, height);
 93  }
 94  
 95  function loadPalette(i) {
 96    palIndex = ((i % PALETTES.length) + PALETTES.length) % PALETTES.length;
 97    const p = PALETTES[palIndex];
 98  
 99    const bc = color(p.bg);
100    BG = [red(bc), green(bc), blue(bc)];
101  
102    const cs = p.stops.map(s => color(s));
103    LUT = [];
104    for (let k = 0; k < LUT_SIZE; k++) {
105      const u = (k / (LUT_SIZE - 1)) * (cs.length - 1);
106      const i0 = floor(u);
107      const i1 = min(i0 + 1, cs.length - 1);
108      const c2 = lerpColor(cs[i0], cs[i1], u - i0);
109      LUT.push([red(c2), green(c2), blue(c2)]);
110    }
111  }
112  
113  /* ---------------------------------------------------------- */
114  
115  function draw() {
116    t += 0.0032;
117    updatePointer();
118  
119    // --- decay the trail buffer toward the ground colour ---
120    trail.blendMode(BLEND);
121    trail.noStroke();
122    trail.fill(BG[0], BG[1], BG[2], 10);
123    trail.rect(0, 0, width, height);
124  
125    // --- shockwaves ---
126    trail.blendMode(ADD);
127    for (let i = waves.length - 1; i >= 0; i--) {
128      const w = waves[i];
129      w.r += w.speed;
130      w.strength *= 0.945;
131      if (w.r > max(width, height) * 1.25 || w.strength < 0.015) {
132        waves.splice(i, 1);
133        continue;
134      }
135      const c = LUT[LUT_SIZE - 1];
136      trail.noFill();
137      trail.strokeWeight(1.2);
138      trail.stroke(c[0], c[1], c[2], w.strength * 70);
139      trail.ellipse(w.x, w.y, w.r * 2, w.r * 2);
140    }
141  
142    // --- motes ---
143    for (let i = 0; i < motes.length; i++) {
144      motes[i].update();
145      motes[i].render();
146    }
147  
148    // --- composite ---
149    blendMode(BLEND);
150    background(BG[0], BG[1], BG[2]);
151  
152    glow.image(trail, 0, 0, glow.width, glow.height);
153    drawingContext.filter = 'blur(9px)';
154    drawingContext.globalAlpha = 0.72;
155    image(glow, 0, 0, width, height);
156    drawingContext.filter = 'none';
157    drawingContext.globalAlpha = 1;
158  
159    image(trail, 0, 0);
160  
161    // --- pointer aura ---
162    if (pointer.inside) {
163      blendMode(ADD);
164      const R = min(width, height) * 0.16;
165      const c = LUT[floor(LUT_SIZE * 0.55)];
166      const g2 = drawingContext.createRadialGradient(pointer.x, pointer.y, 0, pointer.x, pointer.y, R);
167      g2.addColorStop(0, `rgba(${c[0]},${c[1]},${c[2]},0.16)`);
168      g2.addColorStop(1, `rgba(${c[0]},${c[1]},${c[2]},0)`);
169      drawingContext.fillStyle = g2;
170      drawingContext.beginPath();
171      drawingContext.arc(pointer.x, pointer.y, R, 0, TAU);
172      drawingContext.fill();
173      blendMode(BLEND);
174    }
175  
176    // --- click flash ---
177    if (flash > 0.01) {
178      blendMode(ADD);
179      noStroke();
180      fill(255, 255, 255, flash * 26);
181      rect(0, 0, width, height);
182      blendMode(BLEND);
183      flash *= 0.86;
184    }
185  
186    // --- twinkling dust (crisp, above the bloom) ---
187    blendMode(ADD);
188    noStroke();
189    for (const s of sparkles) {
190      s.ph += s.sp;
191      const a = (sin(s.ph) * 0.5 + 0.5);
192      const c = LUT[floor(map(a, 0, 1, LUT_SIZE * 0.3, LUT_SIZE - 1))];
193      fill(c[0], c[1], c[2], 18 + a * 90);
194      ellipse(s.x, s.y, s.r * (1 + a * 0.8), s.r * (1 + a * 0.8));
195    }
196    blendMode(BLEND);
197  
198    // --- grain + vignette ---
199    drawingContext.globalCompositeOperation = 'overlay';
200    drawingContext.globalAlpha = 0.10;
201    image(grainBuf, random(-12, 0), random(-12, 0));
202    drawingContext.globalAlpha = 1;
203    drawingContext.globalCompositeOperation = 'source-over';
204  
205    image(vignetteBuf, 0, 0);
206  
207    drawUI();
208  }
209  
210  /* ---------------------------------------------------------- */
211  
212  class Mote {
213    constructor() { this.respawn(); }
214  
215    respawn() {
216      this.pos = createVector(random(width), random(height));
217      this.prev = this.pos.copy();
218      this.vel = createVector(random(-0.4, 0.4), random(-0.4, 0.4));
219      this.acc = createVector(0, 0);
220      this.life = random(110, 420);
221      this.age = 0;
222      this.maxSpeed = random(1.1, 3.5);
223      this.w = random(0.45, 1.85);
224      this.tint = random(1);
225    }
226  
227    update() {
228      const x = this.pos.x, y = this.pos.y;
229  
230      // Two-octave flow field: a broad swell plus fine turbulence.
231      const a1 = noise(x * 0.0016, y * 0.0016, t) * TAU * 2.2;
232      const a2 = noise(x * 0.0052 + 91.7, y * 0.0052 + 13.3, t * 1.9) * TAU * 2.2;
233      const ang = a1 + a2 * 0.32;
234      this.acc.set(cos(ang) * 0.16, sin(ang) * 0.16);
235  
236      // Stir: swirl + drag transfer around the cursor.
237      if (pointer.inside) {
238        const dx = x - pointer.x, dy = y - pointer.y;
239        const R = min(width, height) * 0.18;
240        const d2 = dx * dx + dy * dy;
241        if (d2 < R * R) {
242          const d = sqrt(d2) + 0.001;
243          const f = 1 - d / R;
244          this.acc.x += (-dy / d) * f * 0.55 + pointer.vx * f * 0.28;
245          this.acc.y += ( dx / d) * f * 0.55 + pointer.vy * f * 0.28;
246        }
247      }
248  
249      // Shockwave impulses.
250      for (const w of waves) {
251        const dx = x - w.x, dy = y - w.y;
252        const d = sqrt(dx * dx + dy * dy) + 0.001;
253        const diff = abs(d - w.r);
254        if (diff < w.thickness) {
255          const f = (1 - diff / w.thickness) * w.strength;
256          this.acc.x += (dx / d) * f;
257          this.acc.y += (dy / d) * f;
258        }
259      }
260  
261      this.vel.add(this.acc);
262      this.vel.mult(0.985);
263      this.vel.limit(this.maxSpeed);
264  
265      this.prev.set(this.pos.x, this.pos.y);
266      this.pos.add(this.vel);
267      this.age++;
268  
269      if (this.age > this.life ||
270          this.pos.x < -30 || this.pos.x > width + 30 ||
271          this.pos.y < -30 || this.pos.y > height + 30) {
272        this.respawn();
273      }
274    }
275  
276    render() {
277      const lifeK = sin(map(this.age, 0, this.life, 0, PI)); // fade in / out
278      const sp = this.vel.mag();
279      const u = constrain(sp / 3.5 * 0.62 + this.tint * 0.38, 0, 0.999);
280      const c = LUT[floor(u * (LUT_SIZE - 1))];
281      const a = constrain(lifeK * (16 + sp * 11), 0, 74);
282  
283      trail.stroke(c[0], c[1], c[2], a);
284      trail.strokeWeight(this.w);
285      trail.line(this.prev.x, this.prev.y, this.pos.x, this.pos.y);
286    }
287  }
288  
289  /* ---------------------------------------------------------- */
290  
291  function updatePointer() {
292    pointer.vx = lerp(pointer.vx, mouseX - pointer.px, 0.35);
293    pointer.vy = lerp(pointer.vy, mouseY - pointer.py, 0.35);
294    pointer.px = mouseX;
295    pointer.py = mouseY;
296    pointer.x = mouseX;
297    pointer.y = mouseY;
298    pointer.inside = mouseX >= 0 && mouseX <= width && mouseY >= 0 && mouseY <= height;
299  }
300  
301  function emitWave(x, y, strength) {
302    waves.push({ x, y, r: 6, speed: random(7, 11), thickness: random(70, 130), strength });
303  }
304  
305  function mousePressed() {
306    emitWave(mouseX, mouseY, 0.95);
307    flash = 1;
308    // Seed a plume of fresh motes at the impact point.
309    for (let i = 0; i < 90; i++) {
310      const m = motes[floor(random(motes.length))];
311      const a = random(TAU), r = random(4, 26);
312      m.pos.set(mouseX + cos(a) * r, mouseY + sin(a) * r);
313      m.prev.set(m.pos.x, m.pos.y);
314      m.vel.set(cos(a) * random(1.5, 4), sin(a) * random(1.5, 4));
315      m.age = 0;
316      m.life = random(140, 300);
317    }
318    uiAlpha = 255; uiTimer = 0;
319    return false;
320  }
321  
322  function mouseMoved() { uiAlpha = 255; uiTimer = 0; }
323  function mouseDragged() { uiAlpha = 255; uiTimer = 0; }
324  
325  function keyPressed() {
326    if (key === ' ') {
327      noiseSeed(floor(random(999999)));
328      trail.blendMode(BLEND);
329      trail.background(BG[0], BG[1], BG[2]);
330      for (const m of motes) m.respawn();
331      waves = [];
332    } else if (key === 'p' || key === 'P') {
333      loadPalette(palIndex + 1);
334      trail.blendMode(BLEND);
335      trail.fill(BG[0], BG[1], BG[2], 120);
336      trail.rect(0, 0, width, height);
337    } else if (key >= '1' && key <= '5') {
338      loadPalette(int(key) - 1);
339    } else if (key === 's' || key === 'S') {
340      saveCanvas('curl-bloom-' + PALETTES[palIndex].name, 'png');
341    }
342    uiAlpha = 255; uiTimer = 0;
343  }
344  
345  function touchMoved() {
346    uiAlpha = 255; uiTimer = 0;
347    return false;
348  }
349  
350  function windowResized() {
351    const oldW = width, oldH = height;
352    resizeCanvas(windowWidth, windowHeight);
353    buildBuffers();
354    trail.background(BG[0], BG[1], BG[2]);
355    for (const m of motes) {
356      m.pos.x = (m.pos.x / oldW) * width;
357      m.pos.y = (m.pos.y / oldH) * height;
358      m.prev.set(m.pos.x, m.pos.y);
359    }
360    for (const s of sparkles) {
361      s.x = (s.x / oldW) * width;
362      s.y = (s.y / oldH) * height;
363    }
364  }
365  
366  /* ---------------------------------------------------------- */
367  
368  function drawUI() {
369    uiTimer++;
370    if (uiTimer > 220) uiAlpha = max(0, uiAlpha - 3.2);
371  
372    const a = uiAlpha;
373    if (a <= 0.5) return;
374  
375    push();
376    blendMode(BLEND);
377    noStroke();
378    textFont('monospace');
379  
380    drawingContext.letterSpacing = '3px';
381    textSize(13);
382    fill(255, 255, 255, a * 0.78);
383    text('CURL BLOOM', 26, 38);
384  
385    drawingContext.letterSpacing = '1.5px';
386    textSize(10);
387    fill(255, 255, 255, a * 0.38);
388    text('palette — ' + PALETTES[palIndex].name, 26, 56);
389  
390    drawingContext.letterSpacing = '0.6px';
391    textSize(10);
392    fill(255, 255, 255, a * 0.34);
393    textAlign(RIGHT, BOTTOM);
394    text('drag to stir   ·   click to pulse   ·   [p] palette   ·   [space] reseed   ·   [s] save',
395         width - 26, height - 24);
396    drawingContext.letterSpacing = '0px';
397    pop();
398  }
399