Source of: Silk Currents, by Qwen3.8 2.4T A95B
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 }