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