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
161
reference/particle-field-demo/README.md
Normal file
161
reference/particle-field-demo/README.md
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
# particle-network
|
||||
|
||||
A canvas animation of drifting particles connected by proximity lines, with
|
||||
opacity modulated by a slowly-rolling fBm (fractal Brownian motion) noise field.
|
||||
|
||||
No external dependencies. Pure ES module.
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `particleNetwork.js` | The module — import this into your project |
|
||||
| `demo.html` | Minimal working example (requires a local server for ES module import) |
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
Serve the folder with any static server, e.g.:
|
||||
|
||||
```bash
|
||||
npx serve .
|
||||
# or
|
||||
python3 -m http.server
|
||||
```
|
||||
|
||||
Then open `demo.html`.
|
||||
|
||||
---
|
||||
|
||||
## Integration
|
||||
|
||||
### 1. Minimal — full-page background
|
||||
|
||||
```html
|
||||
<canvas id="net"></canvas>
|
||||
|
||||
<script type="module">
|
||||
import { ParticleNetwork } from './particleNetwork.js';
|
||||
|
||||
const canvas = document.getElementById('net');
|
||||
|
||||
// Size the canvas to the viewport yourself, or let the module
|
||||
// read the parent element's bounding rect (default behaviour).
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.width = '100%';
|
||||
|
||||
const net = new ParticleNetwork(canvas, null);
|
||||
net.start();
|
||||
</script>
|
||||
```
|
||||
|
||||
### 2. With field preview (debug / design mode)
|
||||
|
||||
```html
|
||||
<canvas id="net"></canvas>
|
||||
<canvas id="field"></canvas>
|
||||
|
||||
<script type="module">
|
||||
import { ParticleNetwork } from './particleNetwork.js';
|
||||
|
||||
const net = new ParticleNetwork(
|
||||
document.getElementById('net'),
|
||||
document.getElementById('field'),
|
||||
{ count: 60, dimAtMin: 0.5 }
|
||||
);
|
||||
|
||||
net.start();
|
||||
</script>
|
||||
```
|
||||
|
||||
### 3. Bundler / framework
|
||||
|
||||
Copy `particleNetwork.js` anywhere in your `src/` tree and import normally:
|
||||
|
||||
```js
|
||||
import { ParticleNetwork } from '@/lib/particleNetwork.js';
|
||||
```
|
||||
|
||||
No build config changes needed — it uses only standard browser APIs.
|
||||
|
||||
---
|
||||
|
||||
## Constructor
|
||||
|
||||
```js
|
||||
new ParticleNetwork(mainCanvas, fieldCanvas, options)
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `mainCanvas` | `HTMLCanvasElement` | The particle animation canvas |
|
||||
| `fieldCanvas` | `HTMLCanvasElement \| null` | Optional debug field view |
|
||||
| `options` | `object` | See below |
|
||||
|
||||
---
|
||||
|
||||
## Options
|
||||
|
||||
All options can be passed at construction time and updated at runtime via
|
||||
`setOption(key, value)`.
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `count` | `55` | Number of particles |
|
||||
| `reach` | `140` | Max connection distance in canvas px |
|
||||
| `flowSpeed` | `2` | Field drift speed on a 1–10 scale. At 1, motion takes several minutes to traverse the field — clearly present but takes time to perceive. |
|
||||
| `fieldScale` | `4` | Noise zoom on a 1–10 scale. Lower = larger rolling blobs. |
|
||||
| `dimAtMin` | `0.60` | Opacity reduction at field minimum. `0` = field has no opacity effect. `1` = particles in troughs are fully invisible. |
|
||||
| `particleSpeed` | `0.35` | Base particle velocity (px/frame) |
|
||||
| `particleMinR` | `1.4` | Minimum particle radius (px) |
|
||||
| `particleMaxR` | `2.8` | Maximum particle radius (px) |
|
||||
| `fieldUpdateMs` | `50` | How often the noise field recalculates (ms). Lower = smoother but more CPU. |
|
||||
| `fieldOctaves` | `4` | fBm octaves. More = finer detail, more CPU. |
|
||||
| `bgColor` | `'#040e1f'` | Canvas background colour |
|
||||
| `lineColor` | `'80,160,240'` | RGB string for particle–particle lines |
|
||||
| `lineColorMouse` | `'160,220,255'` | RGB string for mouse-proximity lines |
|
||||
| `canvasAspect` | `0.65` | Canvas height = width × aspect |
|
||||
| `timeConstantX` | `0.00084` | Primary axis time advance rate. Halve to slow further. |
|
||||
| `timeConstantY` | `0.0003145` | Secondary axis rate — kept at a different ratio for the swirling character. |
|
||||
|
||||
---
|
||||
|
||||
## Methods
|
||||
|
||||
```js
|
||||
net.start() // begin animation loop
|
||||
net.stop() // pause (preserves state)
|
||||
net.setOption(key, value) // update any option at runtime
|
||||
net.sampleField(x, y) // returns [0,1] field value at canvas coords
|
||||
net.destroy() // stop loop and remove all event listeners
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
**Noise field** — a 4-octave fBm Perlin noise function is evaluated on a tiny
|
||||
80×52 offscreen canvas every 50ms, then scaled up via `drawImage`. This is
|
||||
~100× cheaper than evaluating at display resolution.
|
||||
|
||||
**Opacity mapping** — each particle's field sample `fv ∈ [0,1]` maps to an
|
||||
opacity multiplier via:
|
||||
|
||||
```
|
||||
multiplier = (1 - dimAtMin) + dimAtMin * fv
|
||||
```
|
||||
|
||||
At `fv = 1` (field peak): multiplier = 1.0 — full brightness.
|
||||
At `fv = 0` (field trough): multiplier = 1 - dimAtMin.
|
||||
|
||||
For connecting lines the multiplier is the geometric mean of both endpoint
|
||||
values, so a line straddling a bright and dark zone stays dim.
|
||||
|
||||
**Distance falloff** — cubic: `(1 - dist/reach)³`, giving a sharp fade near
|
||||
the connection threshold rather than a linear fade across the full range.
|
||||
|
||||
**Glow** — each particle renders a `createRadialGradient` halo sized to
|
||||
`r × (2.5 + fieldValue × 1.5)`, so dots in bright field regions bloom larger.
|
||||
138
reference/particle-field-demo/demo.html
Normal file
138
reference/particle-field-demo/demo.html
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Particle Network — demo</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background: #040e1f;
|
||||
color: #a0c4f0;
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 13px;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.canvases {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
.canvas-wrap {
|
||||
position: relative;
|
||||
border: 0.5px solid rgba(80, 140, 220, 0.25);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
canvas { display: block; width: 100%; }
|
||||
|
||||
.label {
|
||||
position: absolute;
|
||||
top: 10px; left: 12px;
|
||||
font-size: 11px;
|
||||
color: rgba(120, 180, 255, 0.45);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 20px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
.controls label { color: rgba(160, 200, 255, 0.6); }
|
||||
.controls input[type=range] { width: 100px; accent-color: #5090d0; }
|
||||
|
||||
.dim-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.dim-row input { flex: 1; max-width: 300px; }
|
||||
.dim-row span { min-width: 36px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="canvases">
|
||||
<div class="canvas-wrap">
|
||||
<canvas id="mainCanvas"></canvas>
|
||||
<div class="label">particle network</div>
|
||||
</div>
|
||||
<div class="canvas-wrap">
|
||||
<canvas id="fieldCanvas"></canvas>
|
||||
<div class="label">noise field</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<label>nodes</label>
|
||||
<input type="range" id="sCount" min="20" max="100" value="55" step="1">
|
||||
|
||||
<label>reach</label>
|
||||
<input type="range" id="sReach" min="60" max="260" value="140" step="1">
|
||||
|
||||
<label>flow</label>
|
||||
<input type="range" id="sSpeed" min="1" max="10" value="2" step="1">
|
||||
|
||||
<label>scale</label>
|
||||
<input type="range" id="sScale" min="1" max="10" value="4" step="1">
|
||||
|
||||
<div class="dim-row">
|
||||
<label>dim at minimum</label>
|
||||
<input type="range" id="sDim" min="0" max="100" value="60" step="1">
|
||||
<span id="sDimOut">60%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { ParticleNetwork } from './particleNetwork.js';
|
||||
|
||||
const net = new ParticleNetwork(
|
||||
document.getElementById('mainCanvas'),
|
||||
document.getElementById('fieldCanvas'),
|
||||
{ count: 55, reach: 140, flowSpeed: 2, fieldScale: 4, dimAtMin: 0.60 }
|
||||
);
|
||||
|
||||
net.start();
|
||||
|
||||
// Wire up controls
|
||||
document.getElementById('sCount').addEventListener('input', e =>
|
||||
net.setOption('count', parseInt(e.target.value)));
|
||||
|
||||
document.getElementById('sReach').addEventListener('input', e =>
|
||||
net.setOption('reach', parseInt(e.target.value)));
|
||||
|
||||
document.getElementById('sSpeed').addEventListener('input', e =>
|
||||
net.setOption('flowSpeed', parseFloat(e.target.value)));
|
||||
|
||||
document.getElementById('sScale').addEventListener('input', e =>
|
||||
net.setOption('fieldScale', parseFloat(e.target.value)));
|
||||
|
||||
const dimSlider = document.getElementById('sDim');
|
||||
const dimOut = document.getElementById('sDimOut');
|
||||
dimSlider.addEventListener('input', e => {
|
||||
const v = parseInt(e.target.value);
|
||||
dimOut.textContent = v + '%';
|
||||
net.setOption('dimAtMin', v / 100);
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
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