particle field planning
All checks were successful
deploy / build-and-deploy (push) Successful in 31s
All checks were successful
deploy / build-and-deploy (push) Successful in 31s
This commit is contained in:
parent
ae60a7c856
commit
f4b8e1a7d8
5 changed files with 1685 additions and 0 deletions
419
reference/particle-field-demo/particleNetwork.js
Normal file
419
reference/particle-field-demo/particleNetwork.js
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
/**
|
||||
* particleNetwork.js
|
||||
*
|
||||
* Particle network animation with slow-rolling fBm noise field opacity modulation.
|
||||
*
|
||||
* Usage:
|
||||
* import { ParticleNetwork } from './particleNetwork.js';
|
||||
*
|
||||
* const net = new ParticleNetwork(canvasEl, fieldCanvasEl, {
|
||||
* count: 55, // number of particles
|
||||
* reach: 140, // max connection distance (px)
|
||||
* flowSpeed: 2, // field drift speed (1–10 scale)
|
||||
* fieldScale: 4, // noise zoom level (1–10 scale)
|
||||
* dimAtMin: 0.60, // opacity multiplier at field minimum (0 = no dim, 1 = invisible)
|
||||
* });
|
||||
*
|
||||
* net.start();
|
||||
* net.stop();
|
||||
* net.setOption('count', 80);
|
||||
* net.destroy();
|
||||
*
|
||||
* fieldCanvasEl is optional. Pass null to skip the debug field view.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Perlin noise helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function _fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
|
||||
function _lerp(a, b, t) { return a + t * (b - a); }
|
||||
function _grad(h, x, y) {
|
||||
h &= 7;
|
||||
const u = h < 4 ? x : y, v = h < 4 ? y : x;
|
||||
return ((h & 1) ? -u : u) + ((h & 2) ? -v : v);
|
||||
}
|
||||
|
||||
function _buildPermutation() {
|
||||
const a = Array.from({ length: 256 }, (_, i) => i);
|
||||
for (let i = 255; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return [...a, ...a];
|
||||
}
|
||||
|
||||
function _noise2(P, x, y) {
|
||||
const X = Math.floor(x) & 255, Y = Math.floor(y) & 255;
|
||||
x -= Math.floor(x); y -= Math.floor(y);
|
||||
const u = _fade(x), v = _fade(y);
|
||||
const aa = P[X] + Y, bb = P[X + 1] + Y;
|
||||
return _lerp(
|
||||
_lerp(_grad(P[aa], x, y), _grad(P[bb], x - 1, y), u),
|
||||
_lerp(_grad(P[aa + 1], x, y - 1), _grad(P[bb + 1], x - 1, y - 1), u),
|
||||
v
|
||||
);
|
||||
}
|
||||
|
||||
function _fbm(P, x, y, octaves = 4) {
|
||||
let value = 0, amp = 0.5, freq = 1, max = 0;
|
||||
for (let i = 0; i < octaves; i++) {
|
||||
value += _noise2(P, x * freq, y * freq) * amp;
|
||||
max += amp;
|
||||
amp *= 0.5;
|
||||
freq *= 2;
|
||||
}
|
||||
return value / max;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Defaults
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DEFAULTS = {
|
||||
count: 55,
|
||||
reach: 140,
|
||||
flowSpeed: 2,
|
||||
fieldScale: 4,
|
||||
dimAtMin: 0.60,
|
||||
|
||||
// Particle motion
|
||||
particleSpeed: 0.35,
|
||||
particleMinR: 1.4,
|
||||
particleMaxR: 2.8,
|
||||
|
||||
// Field evaluation
|
||||
fieldOffscreenW: 80,
|
||||
fieldOffscreenH: 52,
|
||||
fieldUpdateMs: 50, // how often the noise field is recalculated (ms)
|
||||
fieldOctaves: 4,
|
||||
|
||||
// Time warp constants — tweak to change how the field drifts
|
||||
// elapsed is in ms; these produce a very slow traversal at flowSpeed=1
|
||||
timeConstantX: 0.000014 * 60, // primary axis
|
||||
timeConstantY: 0.0000085 * 37, // secondary axis (different ratio = swirl)
|
||||
|
||||
// Visual
|
||||
bgColor: '#040e1f',
|
||||
dotColorR: 180,
|
||||
dotColorG: 200, // G channel base (G = R + 20 in original; kept separate for clarity)
|
||||
lineColor: '80,160,240',
|
||||
lineColorMouse: '160,220,255',
|
||||
glowColorInner: '180,220,255',
|
||||
glowColorMid: '100,170,255',
|
||||
canvasAspect: 0.65, // H = W * aspect
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ParticleNetwork class
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class ParticleNetwork {
|
||||
/**
|
||||
* @param {HTMLCanvasElement} mainCanvas
|
||||
* @param {HTMLCanvasElement|null} fieldCanvas — pass null to disable field preview
|
||||
* @param {Partial<typeof DEFAULTS>} options
|
||||
*/
|
||||
constructor(mainCanvas, fieldCanvas = null, options = {}) {
|
||||
this._canvas = mainCanvas;
|
||||
this._fieldCanvas = fieldCanvas;
|
||||
this._opt = { ...DEFAULTS, ...options };
|
||||
|
||||
this._mCtx = mainCanvas.getContext('2d');
|
||||
this._fCtx = fieldCanvas ? fieldCanvas.getContext('2d') : null;
|
||||
|
||||
// Offscreen canvas for cheap noise evaluation
|
||||
this._off = document.createElement('canvas');
|
||||
this._off.width = this._opt.fieldOffscreenW;
|
||||
this._off.height = this._opt.fieldOffscreenH;
|
||||
this._oCtx = this._off.getContext('2d');
|
||||
this._fieldCache = new Float32Array(
|
||||
this._opt.fieldOffscreenW * this._opt.fieldOffscreenH
|
||||
);
|
||||
|
||||
this._P = _buildPermutation();
|
||||
this._particles = [];
|
||||
this._mouse = { x: -9999, y: -9999 };
|
||||
this._elapsed = 0;
|
||||
this._lastTs = null;
|
||||
this._fieldAge = 9999;
|
||||
this._rafId = null;
|
||||
this._W = 0;
|
||||
this._H = 0;
|
||||
|
||||
this._onMouseMove = this._handleMouseMove.bind(this);
|
||||
this._onMouseLeave = this._handleMouseLeave.bind(this);
|
||||
this._onResize = this._handleResize.bind(this);
|
||||
|
||||
this._attach();
|
||||
this._resize();
|
||||
this._syncCount();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
start() {
|
||||
if (this._rafId !== null) return;
|
||||
this._lastTs = null;
|
||||
this._rafId = requestAnimationFrame(ts => this._loop(ts));
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this._rafId !== null) {
|
||||
cancelAnimationFrame(this._rafId);
|
||||
this._rafId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Update a single option at runtime. */
|
||||
setOption(key, value) {
|
||||
this._opt[key] = value;
|
||||
if (key === 'count') this._syncCount();
|
||||
}
|
||||
|
||||
/** Tear down all listeners and stop the loop. */
|
||||
destroy() {
|
||||
this.stop();
|
||||
this._detach();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Event handling
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_attach() {
|
||||
this._canvas.addEventListener('mousemove', this._onMouseMove);
|
||||
this._canvas.addEventListener('mouseleave', this._onMouseLeave);
|
||||
window.addEventListener('resize', this._onResize);
|
||||
}
|
||||
|
||||
_detach() {
|
||||
this._canvas.removeEventListener('mousemove', this._onMouseMove);
|
||||
this._canvas.removeEventListener('mouseleave', this._onMouseLeave);
|
||||
window.removeEventListener('resize', this._onResize);
|
||||
}
|
||||
|
||||
_handleMouseMove(e) {
|
||||
const r = this._canvas.getBoundingClientRect();
|
||||
this._mouse.x = (e.clientX - r.left) * (this._W / r.width);
|
||||
this._mouse.y = (e.clientY - r.top) * (this._H / r.height);
|
||||
}
|
||||
|
||||
_handleMouseLeave() {
|
||||
this._mouse.x = -9999;
|
||||
this._mouse.y = -9999;
|
||||
}
|
||||
|
||||
_handleResize() {
|
||||
this._resize();
|
||||
this._syncCount();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Setup
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_resize() {
|
||||
const rect = this._canvas.parentElement.getBoundingClientRect();
|
||||
this._W = this._canvas.width = Math.floor(rect.width);
|
||||
this._H = this._canvas.height = Math.round(this._W * this._opt.canvasAspect);
|
||||
this._canvas.style.height = this._H + 'px';
|
||||
|
||||
if (this._fieldCanvas) {
|
||||
this._fieldCanvas.width = this._W;
|
||||
this._fieldCanvas.height = this._H;
|
||||
this._fieldCanvas.style.height = this._H + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
_makeParticle() {
|
||||
const { particleSpeed, particleMinR, particleMaxR } = this._opt;
|
||||
return {
|
||||
x: Math.random() * this._W,
|
||||
y: Math.random() * this._H,
|
||||
vx: (Math.random() - 0.5) * particleSpeed,
|
||||
vy: (Math.random() - 0.5) * particleSpeed,
|
||||
r: particleMinR + Math.random() * (particleMaxR - particleMinR),
|
||||
};
|
||||
}
|
||||
|
||||
_syncCount() {
|
||||
const target = this._opt.count;
|
||||
while (this._particles.length < target) this._particles.push(this._makeParticle());
|
||||
while (this._particles.length > target) this._particles.pop();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Field sampling
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sample the cached noise field at canvas coordinates (x, y).
|
||||
* Returns a value in [0, 1].
|
||||
*/
|
||||
sampleField(x, y) {
|
||||
const { fieldOffscreenW: oW, fieldOffscreenH: oH } = this._opt;
|
||||
const cx = Math.max(0, Math.min(oW - 1, Math.round(x / this._W * (oW - 1))));
|
||||
const cy = Math.max(0, Math.min(oH - 1, Math.round(y / this._H * (oH - 1))));
|
||||
return this._fieldCache[cy * oW + cx];
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a raw field value [0,1] to an opacity multiplier.
|
||||
* At fv=1 (maximum): multiplier = 1.0 (no change).
|
||||
* At fv=0 (minimum): multiplier = 1 - dimAtMin.
|
||||
*/
|
||||
_fieldToOpacity(fv) {
|
||||
const minMult = 1 - this._opt.dimAtMin;
|
||||
return minMult + (1 - minMult) * fv;
|
||||
}
|
||||
|
||||
_updateField() {
|
||||
const {
|
||||
fieldOffscreenW: oW, fieldOffscreenH: oH,
|
||||
fieldScale, fieldOctaves,
|
||||
timeConstantX, timeConstantY,
|
||||
} = this._opt;
|
||||
|
||||
const scale = fieldScale * 0.0018;
|
||||
const ox = this._elapsed * timeConstantX;
|
||||
const oy = this._elapsed * timeConstantY;
|
||||
|
||||
const imgData = this._oCtx.createImageData(oW, oH);
|
||||
const d = imgData.data;
|
||||
|
||||
for (let py = 0; py < oH; py++) {
|
||||
for (let px = 0; px < oW; px++) {
|
||||
const raw = _fbm(this._P, px * scale + ox, py * scale + oy, fieldOctaves);
|
||||
const v = Math.max(0, Math.min(1, raw * 1.8 + 0.5));
|
||||
this._fieldCache[py * oW + px] = v;
|
||||
|
||||
const idx = (py * oW + px) * 4;
|
||||
d[idx] = Math.round(v * 30 + (1 - v) * 5);
|
||||
d[idx + 1] = Math.round(v * 110 + (1 - v) * 18);
|
||||
d[idx + 2] = Math.round(v * 210 + (1 - v) * 55);
|
||||
d[idx + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
this._oCtx.putImageData(imgData, 0, 0);
|
||||
|
||||
if (this._fCtx) {
|
||||
this._fCtx.fillStyle = this._opt.bgColor;
|
||||
this._fCtx.fillRect(0, 0, this._W, this._H);
|
||||
this._fCtx.drawImage(this._off, 0, 0, this._W, this._H);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Draw
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_drawMain() {
|
||||
const { bgColor, reach, lineColor, lineColorMouse,
|
||||
glowColorInner, glowColorMid } = this._opt;
|
||||
const { _mCtx: ctx, _W: W, _H: H, _mouse: mouse } = this;
|
||||
const reach2 = reach * reach;
|
||||
|
||||
ctx.fillStyle = bgColor;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Move particles
|
||||
for (const p of this._particles) {
|
||||
p.x += p.vx; p.y += p.vy;
|
||||
if (p.x < 0 || p.x > W) p.vx *= -1;
|
||||
if (p.y < 0 || p.y > H) p.vy *= -1;
|
||||
}
|
||||
|
||||
// Build working set including mouse phantom node
|
||||
const all = [...this._particles, { x: mouse.x, y: mouse.y, r: 0, isMouse: true }];
|
||||
const n = all.length;
|
||||
|
||||
// Sample field values and opacity multipliers for each node
|
||||
const fv = all.map(p => this.sampleField(p.x, p.y));
|
||||
const fo = fv.map(v => this._fieldToOpacity(v));
|
||||
|
||||
// Draw connecting lines
|
||||
for (let i = 0; i < n; i++) {
|
||||
for (let j = i + 1; j < n; j++) {
|
||||
const a = all[i], b = all[j];
|
||||
const dx = a.x - b.x, dy = a.y - b.y;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
if (d2 >= reach2) continue;
|
||||
|
||||
const dist = Math.sqrt(d2);
|
||||
const t01 = dist / reach;
|
||||
const expFade = (1 - t01) * (1 - t01) * (1 - t01); // cubic falloff
|
||||
const lineMult = Math.sqrt(fo[i] * fo[j]); // geometric mean of field mults
|
||||
const alpha = expFade * lineMult * 0.85;
|
||||
if (alpha < 0.005) continue;
|
||||
|
||||
const isMc = a.isMouse || b.isMouse;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(b.x, b.y);
|
||||
ctx.strokeStyle = isMc
|
||||
? `rgba(${lineColorMouse},${alpha * 0.9})`
|
||||
: `rgba(${lineColor},${alpha * 0.7})`;
|
||||
ctx.lineWidth = isMc ? 0.9 : 0.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Draw particles (glow + dot)
|
||||
for (let i = 0; i < this._particles.length; i++) {
|
||||
const p = this._particles[i];
|
||||
const f = fv[i];
|
||||
const opMult = fo[i];
|
||||
|
||||
const dx = p.x - mouse.x, dy = p.y - mouse.y;
|
||||
const md = Math.sqrt(dx * dx + dy * dy);
|
||||
const mb = md < reach ? 0.4 * (1 - md / reach) : 0;
|
||||
|
||||
const gA = Math.max(0.02, opMult * 0.85 + mb);
|
||||
const glowR = p.r * (2.5 + f * 1.5);
|
||||
|
||||
// Radial glow
|
||||
const grd = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowR * 3);
|
||||
grd.addColorStop(0, `rgba(${glowColorInner},${gA * 0.55})`);
|
||||
grd.addColorStop(0.35, `rgba(${glowColorMid},${gA * 0.22})`);
|
||||
grd.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, glowR * 3, 0, Math.PI * 2);
|
||||
ctx.fillStyle = grd;
|
||||
ctx.fill();
|
||||
|
||||
// Hard dot
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.r * (0.8 + f * 0.5), 0, Math.PI * 2);
|
||||
const br = Math.round(180 + f * 75);
|
||||
ctx.fillStyle = `rgba(${br},${br + 20},255,${gA})`;
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Loop
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_loop(ts) {
|
||||
if (this._lastTs === null) this._lastTs = ts;
|
||||
const dt = ts - this._lastTs;
|
||||
this._lastTs = ts;
|
||||
|
||||
// flowSpeed is on a 1–10 scale; divide by 3 to centre the "default" feel
|
||||
const speedMult = this._opt.flowSpeed / 3;
|
||||
this._elapsed += dt * speedMult;
|
||||
|
||||
this._fieldAge += dt;
|
||||
if (this._fieldAge >= this._opt.fieldUpdateMs) {
|
||||
this._updateField();
|
||||
this._fieldAge = 0;
|
||||
}
|
||||
|
||||
this._drawMain();
|
||||
this._rafId = requestAnimationFrame(ts => this._loop(ts));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue