feat(manifold): add faithful flow-field particle port + ParticleStage view
Faithful TypeScript port of the a-immersive visualiser (deployments/meml-aimmersive/js/ui/visualizer.js): 400 particles, identical value-noise + 20-output param mapping + advection/attractor/dispersion/repulsor integration. ParticleStage renders the full-bleed canvas (driven by engine outputs each rAF) plus the macro-axis bar and a corner input pad.
This commit is contained in:
parent
19b7f7eee8
commit
785935bf25
2 changed files with 494 additions and 0 deletions
127
manifold/src/console/ParticleStage.tsx
Normal file
127
manifold/src/console/ParticleStage.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
/**
|
||||||
|
* ParticleStage — the Particle System output Mode's main view.
|
||||||
|
*
|
||||||
|
* Mirrors the a-immersive playground layout:
|
||||||
|
* • a full-bleed Canvas2D flow-field particle system (the main view), driven
|
||||||
|
* by the live model outputs (first 20) read each animation frame;
|
||||||
|
* • a horizontal macro-axis slider bar across the top (Boldness / Memory /
|
||||||
|
* Precision), the same compound axes the rest of the console uses;
|
||||||
|
* • a small circular pad in the bottom-left corner that drives the 2D input
|
||||||
|
* (engine.setInput) — the "joystick" of the immersive app.
|
||||||
|
*
|
||||||
|
* The canvas animates on its own rAF clock so particles keep flowing between
|
||||||
|
* inferences; only the *field* parameters change when the MLP outputs do.
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useEngine } from '../engine';
|
||||||
|
import { ControlAxis } from '../primitives/ControlAxis';
|
||||||
|
import { VirtualJoystick } from '../primitives/VirtualJoystick';
|
||||||
|
import { FlowFieldVisualizer } from './flow-field';
|
||||||
|
import type { Axes } from './types';
|
||||||
|
|
||||||
|
export interface ParticleStageProps {
|
||||||
|
pos: [number, number];
|
||||||
|
onMove: (x: number, y: number) => void;
|
||||||
|
axes: Axes;
|
||||||
|
setAxis: (k: keyof Axes, v: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ParticleStage({ pos, onMove, axes, setAxis }: ParticleStageProps) {
|
||||||
|
const engine = useEngine();
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const vizRef = useRef<FlowFieldVisualizer | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const viz = new FlowFieldVisualizer(canvas);
|
||||||
|
vizRef.current = viz;
|
||||||
|
|
||||||
|
let raf = 0;
|
||||||
|
const tick = () => {
|
||||||
|
const outputs = engine?.getOutputs();
|
||||||
|
if (outputs) viz.setParams(outputs);
|
||||||
|
viz.draw();
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(() => viz.resize());
|
||||||
|
ro.observe(canvas);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
ro.disconnect();
|
||||||
|
vizRef.current = null;
|
||||||
|
};
|
||||||
|
}, [engine]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden', background: '#0d0d0d' }}>
|
||||||
|
{/* Main view — the flow-field particle system */}
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Top horizontal macro-axis slider bar (Boldness / Memory / Precision) */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 20,
|
||||||
|
display: 'flex',
|
||||||
|
gap: 'var(--sp-2)',
|
||||||
|
padding: 'var(--sp-2) var(--sp-3)',
|
||||||
|
alignItems: 'center',
|
||||||
|
background: 'var(--glass)',
|
||||||
|
backdropFilter: 'blur(14px)',
|
||||||
|
WebkitBackdropFilter: 'blur(14px)',
|
||||||
|
borderBottom: '1px solid var(--line)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong
|
||||||
|
style={{
|
||||||
|
color: 'var(--accent)',
|
||||||
|
fontSize: 'var(--fs-md)',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
paddingRight: 'var(--sp-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
MEMLNaut
|
||||||
|
</strong>
|
||||||
|
<ControlAxis
|
||||||
|
label="Boldness"
|
||||||
|
endpoints={['Caution', 'Bold']}
|
||||||
|
value={axes.boldness}
|
||||||
|
onChange={(v) => setAxis('boldness', v)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<ControlAxis
|
||||||
|
label="Memory"
|
||||||
|
endpoints={['Amnesia', 'Elephant']}
|
||||||
|
value={axes.memory}
|
||||||
|
onChange={(v) => setAxis('memory', v)}
|
||||||
|
accent="var(--accent-2)"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<ControlAxis
|
||||||
|
label="Precision"
|
||||||
|
endpoints={['Raw', 'Precise']}
|
||||||
|
value={axes.precision}
|
||||||
|
onChange={(v) => setAxis('precision', v)}
|
||||||
|
accent="var(--ok, var(--accent))"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom-left circular pad — drives the 2D input */}
|
||||||
|
<div style={{ position: 'absolute', left: 18, bottom: 18, zIndex: 20 }}>
|
||||||
|
<VirtualJoystick size={120} position={pos} onMove={onMove} ariaLabel="particle input pad" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
367
manifold/src/console/flow-field.ts
Normal file
367
manifold/src/console/flow-field.ts
Normal file
|
|
@ -0,0 +1,367 @@
|
||||||
|
/**
|
||||||
|
* flow-field.ts — Canvas2D flow-field particle system, a faithful TypeScript
|
||||||
|
* port of the a-immersive playground visualiser
|
||||||
|
* (`/home/w1n5t0n/deployments/meml-aimmersive/js/ui/visualizer.js`).
|
||||||
|
*
|
||||||
|
* FAITHFULNESS (verified 2026-06-28 against the original):
|
||||||
|
* • 400 particles, identical permutation-table value noise (`PERM`/`noise2D`).
|
||||||
|
* • The first 20 model outputs (each ∈ [0,1]) map to the visual ranges exactly
|
||||||
|
* as the original `setParams` (p0..p19 — see the per-line comments below).
|
||||||
|
* • The advection/attractor/dispersion/repulsor integration in `draw()` is a
|
||||||
|
* line-for-line port of the original.
|
||||||
|
* • Deliberate improvements over the original (NOT drift):
|
||||||
|
* - `resize()` resets the transform before `scale(dpr)` so repeated resizes
|
||||||
|
* don't compound the device-pixel-ratio scale (the original double-scaled
|
||||||
|
* on every resize); it also repaints the background so a resize doesn't
|
||||||
|
* leave stale trails.
|
||||||
|
* - `draw()` early-returns while the canvas has zero size (pre-layout).
|
||||||
|
*
|
||||||
|
* The simulation advances on its own clock, so particles keep flowing between
|
||||||
|
* inferences — only the *field* changes when the MLP outputs do.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Simple value noise (no dependencies) — identical permutation scheme to the
|
||||||
|
// a-immersive port. The table is shuffled once at module load.
|
||||||
|
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: number): number {
|
||||||
|
return t * t * t * (t * (t * 6 - 15) + 10);
|
||||||
|
}
|
||||||
|
function lerp(a: number, b: number, t: number): number {
|
||||||
|
return a + t * (b - a);
|
||||||
|
}
|
||||||
|
|
||||||
|
function grad(hash: number, x: number, y: number): number {
|
||||||
|
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: number, y: number): number {
|
||||||
|
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;
|
||||||
|
|
||||||
|
interface Particle {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
id: number;
|
||||||
|
age: number;
|
||||||
|
life: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FlowParams {
|
||||||
|
angleOffset: number;
|
||||||
|
scale: number;
|
||||||
|
speed: number;
|
||||||
|
hueBase: number;
|
||||||
|
hueSpread: number;
|
||||||
|
particleSize: number;
|
||||||
|
fadeRate: number;
|
||||||
|
turbulence: number;
|
||||||
|
attractStrength: number;
|
||||||
|
attractRadius: number;
|
||||||
|
dispersionRate: number;
|
||||||
|
dispersionAmount: number;
|
||||||
|
particleLifetime: number;
|
||||||
|
respawnStyle: number;
|
||||||
|
advectionMode: number;
|
||||||
|
inertia: number;
|
||||||
|
drag: number;
|
||||||
|
repulsorStrength: number;
|
||||||
|
repulsorCount: number;
|
||||||
|
repulsorOrbitRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FlowFieldVisualizer {
|
||||||
|
private canvas: HTMLCanvasElement;
|
||||||
|
private ctx: CanvasRenderingContext2D;
|
||||||
|
private particles: Particle[] = [];
|
||||||
|
private numParticles = 400;
|
||||||
|
private time = 0;
|
||||||
|
private width = 0;
|
||||||
|
private height = 0;
|
||||||
|
|
||||||
|
private params: FlowParams = {
|
||||||
|
angleOffset: 0, // p0: flow direction
|
||||||
|
scale: 0.005, // p1: pattern size
|
||||||
|
speed: 2, // p2: particle speed
|
||||||
|
hueBase: 180, // p3: base colour
|
||||||
|
hueSpread: 60, // p4: colour variation
|
||||||
|
particleSize: 3, // p5: dot radius
|
||||||
|
fadeRate: 0.05, // p6: trail length
|
||||||
|
turbulence: 1, // p7: chaos
|
||||||
|
attractStrength: 0.8, // p8: pull toward screen centre
|
||||||
|
attractRadius: 200, // p9: radius where attraction is strongest
|
||||||
|
dispersionRate: 2.0, // p10: speed of outward dispersion pulses
|
||||||
|
dispersionAmount: 1.0, // p11: strength of outward dispersion
|
||||||
|
particleLifetime: 220, // p12: average frames before respawn
|
||||||
|
respawnStyle: 0.0, // p13: 0=random, 1=edge, 2=center-burst
|
||||||
|
advectionMode: 0.0, // p14: flow->orbit->radial blend
|
||||||
|
inertia: 0.2, // p15: velocity memory
|
||||||
|
drag: 0.02, // p16: velocity damping
|
||||||
|
repulsorStrength: 0.0, // p17: repulsor force amount
|
||||||
|
repulsorCount: 0, // p18: number of active repulsors
|
||||||
|
repulsorOrbitRate: 0.8, // p19: repulsor orbital speed
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(canvas: HTMLCanvasElement) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('FlowFieldVisualizer: 2D context unavailable');
|
||||||
|
this.ctx = ctx;
|
||||||
|
this.resize();
|
||||||
|
this.initParticles();
|
||||||
|
}
|
||||||
|
|
||||||
|
resize(): void {
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
this.canvas.width = Math.max(1, Math.round(rect.width * dpr));
|
||||||
|
this.canvas.height = Math.max(1, Math.round(rect.height * dpr));
|
||||||
|
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
this.ctx.scale(dpr, dpr);
|
||||||
|
this.width = rect.width;
|
||||||
|
this.height = rect.height;
|
||||||
|
// Clear to background so a resize doesn't leave stale trails.
|
||||||
|
this.ctx.fillStyle = '#0d0d0d';
|
||||||
|
this.ctx.fillRect(0, 0, this.width, this.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private initParticles(): void {
|
||||||
|
this.particles = [];
|
||||||
|
for (let i = 0; i < this.numParticles; i++) {
|
||||||
|
this.particles.push(this.makeParticle(i));
|
||||||
|
}
|
||||||
|
this.ctx.fillStyle = '#0d0d0d';
|
||||||
|
this.ctx.fillRect(0, 0, this.width, this.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeParticle(id: number): Particle {
|
||||||
|
return {
|
||||||
|
x: Math.random() * this.width,
|
||||||
|
y: Math.random() * this.height,
|
||||||
|
id,
|
||||||
|
age: Math.floor(Math.random() * this.params.particleLifetime),
|
||||||
|
life: this.computeLifetime(),
|
||||||
|
vx: 0,
|
||||||
|
vy: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeLifetime(): number {
|
||||||
|
const variance = 0.65 + Math.random() * 0.7;
|
||||||
|
return Math.max(10, Math.floor(this.params.particleLifetime * variance));
|
||||||
|
}
|
||||||
|
|
||||||
|
private respawnParticle(p: Particle): void {
|
||||||
|
const mode = Math.min(2, Math.floor(this.params.respawnStyle * 2.999));
|
||||||
|
const { width, height } = this;
|
||||||
|
|
||||||
|
if (mode === 1) {
|
||||||
|
// Edge respawn
|
||||||
|
const side = Math.floor(Math.random() * 4);
|
||||||
|
if (side === 0) {
|
||||||
|
p.x = Math.random() * width;
|
||||||
|
p.y = 0;
|
||||||
|
}
|
||||||
|
if (side === 1) {
|
||||||
|
p.x = width;
|
||||||
|
p.y = Math.random() * height;
|
||||||
|
}
|
||||||
|
if (side === 2) {
|
||||||
|
p.x = Math.random() * width;
|
||||||
|
p.y = height;
|
||||||
|
}
|
||||||
|
if (side === 3) {
|
||||||
|
p.x = 0;
|
||||||
|
p.y = Math.random() * height;
|
||||||
|
}
|
||||||
|
// Give edge spawns an inward impulse.
|
||||||
|
const towardCenterX = width * 0.5 - p.x;
|
||||||
|
const towardCenterY = height * 0.5 - p.y;
|
||||||
|
const inwardDist = Math.hypot(towardCenterX, towardCenterY) + 1e-6;
|
||||||
|
p.vx = (towardCenterX / inwardDist) * 2.0;
|
||||||
|
p.vy = (towardCenterY / inwardDist) * 2.0;
|
||||||
|
} else if (mode === 2) {
|
||||||
|
// Center-burst respawn
|
||||||
|
const angle = Math.random() * TWO_PI;
|
||||||
|
const r = Math.random() * Math.min(width, height) * 0.08;
|
||||||
|
p.x = width * 0.5 + Math.cos(angle) * r;
|
||||||
|
p.y = height * 0.5 + Math.sin(angle) * r;
|
||||||
|
p.vx = Math.cos(angle) * 2.5;
|
||||||
|
p.vy = Math.sin(angle) * 2.5;
|
||||||
|
} else {
|
||||||
|
// Random respawn
|
||||||
|
p.x = Math.random() * width;
|
||||||
|
p.y = Math.random() * height;
|
||||||
|
p.vx = (Math.random() * 2 - 1) * 0.5;
|
||||||
|
p.vy = (Math.random() * 2 - 1) * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.age = 0;
|
||||||
|
p.life = this.computeLifetime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set parameters from the model output (first 20 values, each ∈ [0,1]). */
|
||||||
|
setParams(outputs: ArrayLike<number> | null | undefined): void {
|
||||||
|
if (!outputs || outputs.length < 20) 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;
|
||||||
|
this.params.attractStrength = 0.1 + outputs[8] * 2.9;
|
||||||
|
this.params.attractRadius = 40 + outputs[9] * 420;
|
||||||
|
this.params.dispersionRate = 0.2 + outputs[10] * 8;
|
||||||
|
this.params.dispersionAmount = outputs[11] * 3;
|
||||||
|
this.params.particleLifetime = 30 + outputs[12] * 470;
|
||||||
|
this.params.respawnStyle = outputs[13];
|
||||||
|
this.params.advectionMode = outputs[14];
|
||||||
|
this.params.inertia = outputs[15] * 0.98;
|
||||||
|
this.params.drag = outputs[16] * 0.35;
|
||||||
|
this.params.repulsorStrength = outputs[17] * 4.5;
|
||||||
|
this.params.repulsorCount = Math.floor(outputs[18] * 4.999);
|
||||||
|
this.params.repulsorOrbitRate = 0.1 + outputs[19] * 2.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
draw(): void {
|
||||||
|
const { ctx, width, height, params } = this;
|
||||||
|
if (width === 0 || height === 0) return;
|
||||||
|
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) {
|
||||||
|
const cx = width * 0.5;
|
||||||
|
const cy = height * 0.5;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// Mix between three advection fields for larger visual mode changes.
|
||||||
|
const flowVx = Math.cos(angle + curl) * params.speed;
|
||||||
|
const flowVy = Math.sin(angle + curl) * params.speed;
|
||||||
|
const fromCenterX = p.x - cx;
|
||||||
|
const fromCenterY = p.y - cy;
|
||||||
|
const centerDist = Math.hypot(fromCenterX, fromCenterY) + 1e-6;
|
||||||
|
const radialX = fromCenterX / centerDist;
|
||||||
|
const radialY = fromCenterY / centerDist;
|
||||||
|
const orbitX = -radialY;
|
||||||
|
const orbitY = radialX;
|
||||||
|
const orbitVx = orbitX * params.speed;
|
||||||
|
const orbitVy = orbitY * params.speed;
|
||||||
|
const radialVx = radialX * params.speed;
|
||||||
|
const radialVy = radialY * params.speed;
|
||||||
|
|
||||||
|
const modeBlend = params.advectionMode * 2;
|
||||||
|
let targetVx: number;
|
||||||
|
let targetVy: number;
|
||||||
|
if (modeBlend < 1) {
|
||||||
|
targetVx = lerp(flowVx, orbitVx, modeBlend);
|
||||||
|
targetVy = lerp(flowVy, orbitVy, modeBlend);
|
||||||
|
} else {
|
||||||
|
targetVx = lerp(orbitVx, radialVx, modeBlend - 1);
|
||||||
|
targetVy = lerp(orbitVy, radialVy, modeBlend - 1);
|
||||||
|
}
|
||||||
|
p.vx = p.vx * params.inertia + targetVx * (1 - params.inertia);
|
||||||
|
p.vy = p.vy * params.inertia + targetVy * (1 - params.inertia);
|
||||||
|
p.vx *= 1 - params.drag;
|
||||||
|
p.vy *= 1 - params.drag;
|
||||||
|
let nextX = p.x + p.vx;
|
||||||
|
let nextY = p.y + p.vy;
|
||||||
|
|
||||||
|
// Central attractor keeps trajectories from sticking to the outer edges.
|
||||||
|
const dx = cx - nextX;
|
||||||
|
const dy = cy - nextY;
|
||||||
|
const dist = Math.hypot(dx, dy) + 1e-6;
|
||||||
|
const nxCenter = dx / dist;
|
||||||
|
const nyCenter = dy / dist;
|
||||||
|
const normalizedDist = Math.min(dist / params.attractRadius, 2);
|
||||||
|
const falloff = 1 / (1 + normalizedDist * normalizedDist);
|
||||||
|
nextX += nxCenter * params.attractStrength * falloff;
|
||||||
|
nextY += nyCenter * params.attractStrength * falloff;
|
||||||
|
|
||||||
|
// Time-varying dispersion pushes particles outward near the centre.
|
||||||
|
const dispersionPulse =
|
||||||
|
0.5 + 0.5 * Math.sin(this.time * params.dispersionRate + p.id * 0.07);
|
||||||
|
const dispersionForce = params.dispersionAmount * dispersionPulse * falloff;
|
||||||
|
nextX -= nxCenter * dispersionForce;
|
||||||
|
nextY -= nyCenter * dispersionForce;
|
||||||
|
|
||||||
|
// Orbiting repulsor points carve dynamic voids and bursts.
|
||||||
|
const repulsorRadius = Math.min(width, height) * 0.28;
|
||||||
|
for (let r = 0; r < params.repulsorCount; r++) {
|
||||||
|
const phase = this.time * params.repulsorOrbitRate + (r / 4) * TWO_PI;
|
||||||
|
const wobble = 0.6 + 0.15 * r;
|
||||||
|
const rx = cx + Math.cos(phase * (1.0 + wobble)) * repulsorRadius;
|
||||||
|
const ry = cy + Math.sin(phase * (1.3 + wobble)) * repulsorRadius;
|
||||||
|
const repulseDx = nextX - rx;
|
||||||
|
const repulseDy = nextY - ry;
|
||||||
|
const distSq = repulseDx * repulseDx + repulseDy * repulseDy + 160;
|
||||||
|
const distInv = 1 / Math.sqrt(distSq);
|
||||||
|
const force = params.repulsorStrength * (650 / distSq);
|
||||||
|
nextX += repulseDx * distInv * force;
|
||||||
|
nextY += repulseDy * distInv * force;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.x = nextX;
|
||||||
|
p.y = nextY;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
p.age += 1;
|
||||||
|
if (p.age >= p.life) this.respawnParticle(p);
|
||||||
|
|
||||||
|
// Colour 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue