147 lines
4.2 KiB
JavaScript
147 lines
4.2 KiB
JavaScript
|
|
// Flow field particle system with Canvas2D
|
||
|
|
// Controlled by 8 output parameters from the IML network
|
||
|
|
|
||
|
|
// Simple value noise (no dependencies)
|
||
|
|
const PERM = new Uint8Array(512);
|
||
|
|
{
|
||
|
|
const p = new Uint8Array(256);
|
||
|
|
for (let i = 0; i < 256; i++) p[i] = i;
|
||
|
|
for (let i = 255; i > 0; i--) {
|
||
|
|
const j = Math.floor(Math.random() * (i + 1));
|
||
|
|
[p[i], p[j]] = [p[j], p[i]];
|
||
|
|
}
|
||
|
|
for (let i = 0; i < 512; i++) PERM[i] = p[i & 255];
|
||
|
|
}
|
||
|
|
|
||
|
|
function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
|
||
|
|
function lerp(a, b, t) { return a + t * (b - a); }
|
||
|
|
|
||
|
|
function grad(hash, x, y) {
|
||
|
|
const h = hash & 3;
|
||
|
|
const u = h < 2 ? x : y;
|
||
|
|
const v = h < 2 ? y : x;
|
||
|
|
return ((h & 1) ? -u : u) + ((h & 2) ? -v : v);
|
||
|
|
}
|
||
|
|
|
||
|
|
function noise2D(x, y) {
|
||
|
|
const X = Math.floor(x) & 255;
|
||
|
|
const Y = Math.floor(y) & 255;
|
||
|
|
const xf = x - Math.floor(x);
|
||
|
|
const yf = y - Math.floor(y);
|
||
|
|
const u = fade(xf);
|
||
|
|
const v = fade(yf);
|
||
|
|
|
||
|
|
const aa = PERM[PERM[X] + Y];
|
||
|
|
const ab = PERM[PERM[X] + Y + 1];
|
||
|
|
const ba = PERM[PERM[X + 1] + Y];
|
||
|
|
const bb = PERM[PERM[X + 1] + Y + 1];
|
||
|
|
|
||
|
|
return lerp(
|
||
|
|
lerp(grad(aa, xf, yf), grad(ba, xf - 1, yf), u),
|
||
|
|
lerp(grad(ab, xf, yf - 1), grad(bb, xf - 1, yf - 1), u),
|
||
|
|
v
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const TWO_PI = Math.PI * 2;
|
||
|
|
|
||
|
|
export class FlowFieldVisualizer {
|
||
|
|
constructor(canvas) {
|
||
|
|
this.canvas = canvas;
|
||
|
|
this.ctx = canvas.getContext('2d');
|
||
|
|
this.particles = [];
|
||
|
|
this.numParticles = 400;
|
||
|
|
this.time = 0;
|
||
|
|
|
||
|
|
// Parameters (all 0-1 from IML, mapped to visual ranges)
|
||
|
|
this.params = {
|
||
|
|
angleOffset: 0, // p0: flow direction
|
||
|
|
scale: 0.005, // p1: pattern size
|
||
|
|
speed: 2, // p2: particle speed
|
||
|
|
hueBase: 180, // p3: base color
|
||
|
|
hueSpread: 60, // p4: color variation
|
||
|
|
particleSize: 3, // p5: dot radius
|
||
|
|
fadeRate: 0.05, // p6: trail length
|
||
|
|
turbulence: 1, // p7: chaos
|
||
|
|
};
|
||
|
|
|
||
|
|
this.resize();
|
||
|
|
this.initParticles();
|
||
|
|
}
|
||
|
|
|
||
|
|
resize() {
|
||
|
|
const rect = this.canvas.getBoundingClientRect();
|
||
|
|
const dpr = window.devicePixelRatio || 1;
|
||
|
|
this.canvas.width = rect.width * dpr;
|
||
|
|
this.canvas.height = rect.height * dpr;
|
||
|
|
this.ctx.scale(dpr, dpr);
|
||
|
|
this.width = rect.width;
|
||
|
|
this.height = rect.height;
|
||
|
|
}
|
||
|
|
|
||
|
|
initParticles() {
|
||
|
|
this.particles = [];
|
||
|
|
for (let i = 0; i < this.numParticles; i++) {
|
||
|
|
this.particles.push({
|
||
|
|
x: Math.random() * this.width,
|
||
|
|
y: Math.random() * this.height,
|
||
|
|
id: i,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
// Clear canvas to black
|
||
|
|
this.ctx.fillStyle = '#0d0d0d';
|
||
|
|
this.ctx.fillRect(0, 0, this.width, this.height);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Set parameters from IML output (all values 0-1)
|
||
|
|
setParams(outputs) {
|
||
|
|
if (!outputs || outputs.length < 8) return;
|
||
|
|
this.params.angleOffset = outputs[0] * TWO_PI;
|
||
|
|
this.params.scale = 0.001 + outputs[1] * 0.009;
|
||
|
|
this.params.speed = 0.5 + outputs[2] * 4.5;
|
||
|
|
this.params.hueBase = outputs[3] * 360;
|
||
|
|
this.params.hueSpread = outputs[4] * 120;
|
||
|
|
this.params.particleSize = 1 + outputs[5] * 5;
|
||
|
|
this.params.fadeRate = 0.01 + outputs[6] * 0.14;
|
||
|
|
this.params.turbulence = outputs[7] * 2;
|
||
|
|
}
|
||
|
|
|
||
|
|
draw() {
|
||
|
|
const { ctx, width, height, params } = this;
|
||
|
|
this.time += 0.003;
|
||
|
|
|
||
|
|
// Fade existing content (creates trails)
|
||
|
|
ctx.fillStyle = `rgba(13, 13, 13, ${params.fadeRate})`;
|
||
|
|
ctx.fillRect(0, 0, width, height);
|
||
|
|
|
||
|
|
for (const p of this.particles) {
|
||
|
|
// Sample flow field
|
||
|
|
const nx = p.x * params.scale;
|
||
|
|
const ny = p.y * params.scale;
|
||
|
|
const angle = noise2D(nx + this.time, ny) * TWO_PI + params.angleOffset;
|
||
|
|
const curl = noise2D(nx + 100, ny + 100 + this.time * 0.5) * params.turbulence;
|
||
|
|
|
||
|
|
// Move particle
|
||
|
|
const vx = Math.cos(angle + curl) * params.speed;
|
||
|
|
const vy = Math.sin(angle + curl) * params.speed;
|
||
|
|
p.x += vx;
|
||
|
|
p.y += vy;
|
||
|
|
|
||
|
|
// Wrap around edges
|
||
|
|
if (p.x < 0) p.x += width;
|
||
|
|
if (p.x > width) p.x -= width;
|
||
|
|
if (p.y < 0) p.y += height;
|
||
|
|
if (p.y > height) p.y -= height;
|
||
|
|
|
||
|
|
// Color based on particle id + hue params
|
||
|
|
const hue = (params.hueBase + (p.id / this.numParticles) * params.hueSpread) % 360;
|
||
|
|
const lightness = 50 + Math.sin(p.id * 0.1 + this.time) * 15;
|
||
|
|
|
||
|
|
ctx.fillStyle = `hsl(${hue}, 75%, ${lightness}%)`;
|
||
|
|
ctx.beginPath();
|
||
|
|
ctx.arc(p.x, p.y, params.particleSize, 0, TWO_PI);
|
||
|
|
ctx.fill();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|