Source of: Curl Bloom, by Qwen3.8 2.4T A95B
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