diff --git a/.gitignore b/.gitignore
index f71cc41..7a49c15 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ npm-debug.log
*.png
.env
.DS_Store
+.venv
\ No newline at end of file
diff --git a/index.html b/index.html
index 08afbfd..4b9e992 100644
--- a/index.html
+++ b/index.html
@@ -1,20 +1,26 @@
- 3d example using three.js and multiple windows
+ Futuristic Particle Network — Multi-Window
+
+
+
-
\ No newline at end of file
diff --git a/main.js b/main.js
index f2091fa..7a1f15c 100644
--- a/main.js
+++ b/main.js
@@ -1,197 +1,432 @@
import WindowManager from './WindowManager.js'
-
-
-
-const t = THREE;
-let camera, scene, renderer, world;
-let near, far;
-let pixR = window.devicePixelRatio ? window.devicePixelRatio : 1;
-let cubes = [];
-let sceneOffsetTarget = {x: 0, y: 0};
-let sceneOffset = {x: 0, y: 0};
-
-let today = new Date();
-today.setHours(0);
-today.setMinutes(0);
-today.setSeconds(0);
-today.setMilliseconds(0);
-today = today.getTime();
-
-let internalTime = getTime();
-let windowManager;
-let initialized = false;
-
-// get time in seconds since beginning of the day (so that all windows use the same time)
-function getTime ()
-{
- return (new Date().getTime() - today) / 1000.0;
+const T = THREE, PI2 = Math.PI * 2;
+
+// ── Smooth noise (value noise with hermite interpolation) ──────────
+const PERM = new Uint8Array(512);
+for (let i = 0; i < 256; i++) PERM[i] = PERM[i + 256] = (i * 167 + 53) & 255;
+function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
+function lerp(a, b, t) { return a + (b - a) * t; }
+function grad(h, x, y) {
+ const a = h & 3;
+ return (a === 0 ? x + y : a === 1 ? -x + y : a === 2 ? x - y : -x - y);
}
-
-
-if (new URLSearchParams(window.location.search).get("clear"))
-{
- localStorage.clear();
+function noise2d(x, y) {
+ const xi = Math.floor(x) & 255, yi = Math.floor(y) & 255;
+ const xf = x - Math.floor(x), yf = y - Math.floor(y);
+ const u = fade(xf), v = fade(yf);
+ const aa = PERM[PERM[xi] + yi], ab = PERM[PERM[xi] + yi + 1];
+ const ba = PERM[PERM[xi + 1] + yi], bb = PERM[PERM[xi + 1] + yi + 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);
+}
+function fbm(x, y, oct) {
+ let v = 0, amp = 1, freq = 1, tot = 0;
+ for (let i = 0; i < oct; i++) { v += noise2d(x * freq, y * freq) * amp; tot += amp; amp *= .5; freq *= 2; }
+ return v / tot;
}
-else
-{
- // this code is essential to circumvent that some browsers preload the content of some pages before you actually hit the url
- document.addEventListener("visibilitychange", () =>
- {
- if (document.visibilityState != 'hidden' && !initialized)
- {
- init();
- }
- });
-
- window.onload = () => {
- if (document.visibilityState != 'hidden')
- {
- init();
- }
- };
-
- function init ()
- {
- initialized = true;
-
- // add a short timeout because window.offsetX reports wrong values before a short period
- setTimeout(() => {
- setupScene();
- setupWindowManager();
- resize();
- updateWindowShape(false);
- render();
- window.addEventListener('resize', resize);
- }, 500)
- }
-
- function setupScene ()
- {
- camera = new t.OrthographicCamera(0, 0, window.innerWidth, window.innerHeight, -10000, 10000);
-
- camera.position.z = 2.5;
- near = camera.position.z - .5;
- far = camera.position.z + 0.5;
-
- scene = new t.Scene();
- scene.background = new t.Color(0.0);
- scene.add( camera );
-
- renderer = new t.WebGLRenderer({antialias: true, depthBuffer: true});
- renderer.setPixelRatio(pixR);
-
- world = new t.Object3D();
- scene.add(world);
-
- renderer.domElement.setAttribute("id", "scene");
- document.body.appendChild( renderer.domElement );
- }
-
- function setupWindowManager ()
- {
- windowManager = new WindowManager();
- windowManager.setWinShapeChangeCallback(updateWindowShape);
- windowManager.setWinChangeCallback(windowsUpdated);
-
- // here you can add your custom metadata to each windows instance
- let metaData = {foo: "bar"};
-
- // this will init the windowmanager and add this window to the centralised pool of windows
- windowManager.init(metaData);
-
- // call update windows initially (it will later be called by the win change callback)
- windowsUpdated();
- }
-
- function windowsUpdated ()
- {
- updateNumberOfCubes();
- }
-
- function updateNumberOfCubes ()
- {
- let wins = windowManager.getWindows();
-
- // remove all cubes
- cubes.forEach((c) => {
- world.remove(c);
- })
-
- cubes = [];
-
- // add new cubes based on the current window setup
- for (let i = 0; i < wins.length; i++)
- {
- let win = wins[i];
-
- let c = new t.Color();
- c.setHSL(i * .1, 1.0, .5);
-
- let s = 100 + i * 50;
- let cube = new t.Mesh(new t.BoxGeometry(s, s, s), new t.MeshBasicMaterial({color: c , wireframe: true}));
- cube.position.x = win.shape.x + (win.shape.w * .5);
- cube.position.y = win.shape.y + (win.shape.h * .5);
-
- world.add(cube);
- cubes.push(cube);
- }
- }
-
- function updateWindowShape (easing = true)
- {
- // storing the actual offset in a proxy that we update against in the render function
- sceneOffsetTarget = {x: -window.screenX, y: -window.screenY};
- if (!easing) sceneOffset = sceneOffsetTarget;
- }
-
-
- function render ()
- {
- let t = getTime();
-
- windowManager.update();
-
-
- // calculate the new position based on the delta between current offset and new offset times a falloff value (to create the nice smoothing effect)
- let falloff = .05;
- sceneOffset.x = sceneOffset.x + ((sceneOffsetTarget.x - sceneOffset.x) * falloff);
- sceneOffset.y = sceneOffset.y + ((sceneOffsetTarget.y - sceneOffset.y) * falloff);
-
- // set the world position to the offset
- world.position.x = sceneOffset.x;
- world.position.y = sceneOffset.y;
-
- let wins = windowManager.getWindows();
-
-
- // loop through all our cubes and update their positions based on current window positions
- for (let i = 0; i < cubes.length; i++)
- {
- let cube = cubes[i];
- let win = wins[i];
- let _t = t;// + i * .2;
-
- let posTarget = {x: win.shape.x + (win.shape.w * .5), y: win.shape.y + (win.shape.h * .5)}
- cube.position.x = cube.position.x + (posTarget.x - cube.position.x) * falloff;
- cube.position.y = cube.position.y + (posTarget.y - cube.position.y) * falloff;
- cube.rotation.x = _t * .5;
- cube.rotation.y = _t * .3;
- };
-
- renderer.render(scene, camera);
- requestAnimationFrame(render);
- }
+// ── Config ─────────────────────────────────────────────────────────
+const C = {
+ BG: 0x020110,
+ // Particles per window
+ FLOW_COUNT: 400,
+ TRAIL_LEN: 14,
+ // Flow field
+ FLOW_SCALE: 0.0015,
+ FLOW_SPEED: 1.4,
+ FLOW_FORCE: 0.5,
+ // Attraction to window center
+ CENTER_PULL: 0.004,
+ WANDER_RADIUS: 300,
+ // Cross-window streams
+ STREAM_PARTICLES: 80,
+ STREAM_TRAIL: 20,
+ // Background
+ BG_PARTICLES: 350,
+ // Visuals
+ HUES: [.55, .82, .35, .08, .72, .95, .15, .45],
+};
+
+// ── Globals ────────────────────────────────────────────────────────
+let camera, scene, renderer, world, windowManager, initialized = false;
+let pixR = window.devicePixelRatio || 1;
+let soT = { x: 0, y: 0 }, so = { x: 0, y: 0 };
+let today = new Date(); today.setHours(0, 0, 0, 0); today = today.getTime();
+const gTime = () => (Date.now() - today) / 1000;
+
+let bgSystem = null;
+let winLayers = [];
+let streamLayers = [];
+
+// ── Glow texture ───────────────────────────────────────────────────
+function mkGlow(sz, softness) {
+ const cv = document.createElement('canvas'); cv.width = cv.height = sz;
+ const ctx = cv.getContext('2d'), c = sz / 2;
+ const g = ctx.createRadialGradient(c, c, 0, c, c, c);
+ g.addColorStop(0, 'rgba(255,255,255,1)');
+ g.addColorStop(softness, 'rgba(220,235,255,0.7)');
+ g.addColorStop(softness + .25, 'rgba(150,190,255,0.3)');
+ g.addColorStop(1, 'rgba(0,0,0,0)');
+ ctx.fillStyle = g; ctx.fillRect(0, 0, sz, sz);
+ return new T.CanvasTexture(cv);
+}
+function mkSoftGlow(sz) {
+ const cv = document.createElement('canvas'); cv.width = cv.height = sz;
+ const ctx = cv.getContext('2d'), c = sz / 2;
+ const g = ctx.createRadialGradient(c, c, 0, c, c, c);
+ g.addColorStop(0, 'rgba(255,255,255,0.9)');
+ g.addColorStop(.25, 'rgba(200,220,255,0.4)');
+ g.addColorStop(.6, 'rgba(120,160,255,0.1)');
+ g.addColorStop(1, 'rgba(0,0,0,0)');
+ ctx.fillStyle = g; ctx.fillRect(0, 0, sz, sz);
+ return new T.CanvasTexture(cv);
+}
- // resize the renderer to fit the window size
- function resize ()
- {
- let width = window.innerWidth;
- let height = window.innerHeight
-
- camera = new t.OrthographicCamera(0, width, 0, height, -10000, 10000);
- camera.updateProjectionMatrix();
- renderer.setSize( width, height );
- }
+let texMain, texTrail, texBg, texStream;
+
+// ── Init ───────────────────────────────────────────────────────────
+if (new URLSearchParams(location.search).get("clear")) { localStorage.clear(); }
+else {
+ document.addEventListener("visibilitychange", () => { if (document.visibilityState != 'hidden' && !initialized) init(); });
+ window.onload = () => { if (document.visibilityState != 'hidden') init(); };
+
+ function init() {
+ initialized = true;
+ setTimeout(() => {
+ texMain = mkGlow(64, .1);
+ texTrail = mkSoftGlow(32);
+ texBg = mkSoftGlow(16);
+ texStream = mkGlow(48, .08);
+ setupScene(); setupWM(); mkBackground(); resize();
+ updateWS(false); render(); addEventListener('resize', resize);
+ }, 500);
+ }
+
+ function setupScene() {
+ camera = new T.OrthographicCamera(0, 0, innerWidth, innerHeight, -10000, 10000);
+ camera.position.z = 2.5;
+ scene = new T.Scene(); scene.background = new T.Color(C.BG);
+ scene.add(camera);
+ renderer = new T.WebGLRenderer({ antialias: true });
+ renderer.setPixelRatio(pixR);
+ world = new T.Object3D(); scene.add(world);
+ renderer.domElement.id = "scene"; document.body.appendChild(renderer.domElement);
+ }
+
+ function setupWM() {
+ windowManager = new WindowManager();
+ windowManager.setWinShapeChangeCallback(updateWS);
+ windowManager.setWinChangeCallback(() => rebuild());
+ windowManager.init({}); rebuild();
+ }
+
+ // ── Background ambient particles ────────────────────────────────
+ function mkBackground() {
+ let n = C.BG_PARTICLES;
+ let pos = new Float32Array(n * 3), col = new Float32Array(n * 3), vel = [];
+ for (let i = 0; i < n; i++) {
+ pos[i * 3] = (Math.random() - .5) * 4000 + screen.width / 2;
+ pos[i * 3 + 1] = (Math.random() - .5) * 4000 + screen.height / 2;
+ pos[i * 3 + 2] = (Math.random() - .5) * 50;
+ let c = new T.Color(); c.setHSL(.6 + Math.random() * .2, .5, .15 + Math.random() * .12);
+ col[i * 3] = c.r; col[i * 3 + 1] = c.g; col[i * 3 + 2] = c.b;
+ vel.push({ x: (Math.random() - .5) * .1, y: (Math.random() - .5) * .1 });
+ }
+ let g = new T.BufferGeometry();
+ g.setAttribute('position', new T.BufferAttribute(pos, 3));
+ g.setAttribute('color', new T.BufferAttribute(col, 3));
+ let m = new T.PointsMaterial({ size: 3, map: texBg, vertexColors: true, transparent: true,
+ opacity: .7, blending: T.AdditiveBlending, depthWrite: false, sizeAttenuation: false });
+ let p = new T.Points(g, m); world.add(p);
+ bgSystem = { points: p, geo: g, vel };
+ }
+
+ // ── Create flow particles for a window ──────────────────────────
+ function createFlowSystem(cx, cy, hue, count, trailLen, tex, baseSize) {
+ let particles = [];
+ for (let i = 0; i < count; i++) {
+ let angle = Math.random() * PI2;
+ let r = Math.random() * C.WANDER_RADIUS;
+ let x = cx + Math.cos(angle) * r;
+ let y = cy + Math.sin(angle) * r;
+ // trail: array of past positions
+ let trail = [];
+ for (let t = 0; t < trailLen; t++) trail.push({ x, y, z: 0 });
+ let c = new T.Color();
+ c.setHSL(hue + (Math.random() - .5) * .08, .8 + Math.random() * .15, .5 + Math.random() * .4);
+ particles.push({
+ x, y, z: (Math.random() - .5) * 20,
+ vx: 0, vy: 0,
+ trail, color: c,
+ phase: Math.random() * PI2,
+ size: baseSize * (.6 + Math.random() * .8)
+ });
+ }
+ // Build points for head + all trail positions
+ let total = count * (1 + trailLen);
+ let pos = new Float32Array(total * 3);
+ let col = new Float32Array(total * 3);
+ let geo = new T.BufferGeometry();
+ geo.setAttribute('position', new T.BufferAttribute(pos, 3));
+ geo.setAttribute('color', new T.BufferAttribute(col, 3));
+ let mat = new T.PointsMaterial({ size: baseSize, map: tex, vertexColors: true,
+ transparent: true, opacity: .95, blending: T.AdditiveBlending,
+ depthWrite: false, sizeAttenuation: false });
+ let pts = new T.Points(geo, mat);
+ world.add(pts);
+ return { particles, geo, pts, mat, trailLen, count };
+ }
+
+ // ── Rebuild everything ──────────────────────────────────────────
+ function rebuild() {
+ let wins = windowManager.getWindows();
+ // Cleanup
+ winLayers.forEach(w => { world.remove(w.flow.pts); world.remove(w.connLine); });
+ streamLayers.forEach(s => world.remove(s.flow.pts));
+ winLayers = []; streamLayers = [];
+
+ for (let i = 0; i < wins.length; i++) {
+ let w = wins[i], hue = C.HUES[i % C.HUES.length];
+ let cx = w.shape.x + w.shape.w * .5, cy = w.shape.y + w.shape.h * .5;
+ let flow = createFlowSystem(cx, cy, hue, C.FLOW_COUNT, C.TRAIL_LEN, texMain, 7);
+ // Soft connection lines
+ let clMat = new T.LineBasicMaterial({ color: new T.Color().setHSL(hue, .7, .35),
+ transparent: true, opacity: .12, blending: T.AdditiveBlending, depthWrite: false });
+ let clGeo = new T.BufferGeometry();
+ let cl = new T.LineSegments(clGeo, clMat);
+ world.add(cl);
+ winLayers.push({ flow, connLine: cl, hue, winId: w.id });
+ }
+
+ // Cross-window flowing streams
+ for (let i = 0; i < wins.length; i++) {
+ for (let j = i + 1; j < wins.length; j++) {
+ let hMix = (C.HUES[i % 8] + C.HUES[j % 8]) / 2;
+ let wA = wins[i], wB = wins[j];
+ let cxA = wA.shape.x + wA.shape.w * .5, cyA = wA.shape.y + wA.shape.h * .5;
+ let cxB = wB.shape.x + wB.shape.w * .5, cyB = wB.shape.y + wB.shape.h * .5;
+ let midX = (cxA + cxB) / 2, midY = (cyA + cyB) / 2;
+ let flow = createFlowSystem(midX, midY, hMix, C.STREAM_PARTICLES, C.STREAM_TRAIL, texStream, 6);
+ // Initialize stream particles along the path between windows
+ for (let p = 0; p < flow.particles.length; p++) {
+ let frac = p / flow.particles.length;
+ let px = cxA + (cxB - cxA) * frac + (Math.random() - .5) * 60;
+ let py = cyA + (cyB - cyA) * frac + (Math.random() - .5) * 60;
+ flow.particles[p].x = px; flow.particles[p].y = py;
+ for (let t = 0; t < flow.trailLen; t++) {
+ flow.particles[p].trail[t].x = px; flow.particles[p].trail[t].y = py;
+ }
+ }
+ streamLayers.push({ flow, idxA: i, idxB: j });
+ }
+ }
+ }
+
+ function updateWS(easing = true) {
+ soT = { x: -screenX, y: -screenY };
+ if (!easing) so = soT;
+ }
+
+ // ── Update a flow system with noise field ───────────────────────
+ function updateFlow(sys, cx, cy, time, pull, wanderR, flowScale, flowSpeed) {
+ let ps = sys.particles;
+ let pos = sys.geo.attributes.position.array;
+ let col = sys.geo.attributes.color.array;
+ let tl = sys.trailLen, n = sys.count;
+
+ for (let i = 0; i < n; i++) {
+ let p = ps[i];
+ // Flow field force from noise
+ let nx = p.x * flowScale;
+ let ny = p.y * flowScale;
+ let angle = fbm(nx + time * .12, ny + time * .08, 4) * PI2 * 2;
+ let fx = Math.cos(angle) * C.FLOW_FORCE;
+ let fy = Math.sin(angle) * C.FLOW_FORCE;
+
+ // Gentle pull toward center
+ let dx = cx - p.x, dy = cy - p.y;
+ let dist = Math.sqrt(dx * dx + dy * dy);
+ let pullF = pull;
+ if (dist > wanderR) pullF += (dist - wanderR) * .002;
+ fx += dx * pullF;
+ fy += dy * pullF;
+
+ // Smooth velocity (high damping = liquid feel)
+ p.vx += fx * .15;
+ p.vy += fy * .15;
+ p.vx *= .92;
+ p.vy *= .92;
+
+ // Subtle sine wave undulation
+ p.vx += Math.sin(time * .7 + p.phase) * .08;
+ p.vy += Math.cos(time * .5 + p.phase * 1.3) * .08;
+
+ // Update position
+ p.x += p.vx * flowSpeed;
+ p.y += p.vy * flowSpeed;
+ p.z = Math.sin(time * .4 + p.phase) * 12;
+
+ // Shift trail (unshift new, pop old)
+ p.trail.pop();
+ p.trail.unshift({ x: p.x, y: p.y, z: p.z });
+
+ // Write head particle
+ let idx = i * (1 + tl);
+ pos[idx * 3] = p.x; pos[idx * 3 + 1] = p.y; pos[idx * 3 + 2] = p.z;
+ // Head color: full brightness
+ col[idx * 3] = p.color.r; col[idx * 3 + 1] = p.color.g; col[idx * 3 + 2] = p.color.b;
+
+ // Write trail particles with fading color
+ for (let t = 0; t < tl; t++) {
+ let ti = idx + 1 + t;
+ let tr = p.trail[t];
+ pos[ti * 3] = tr.x; pos[ti * 3 + 1] = tr.y; pos[ti * 3 + 2] = tr.z;
+ let fade = 1 - (t + 1) / (tl + 1);
+ fade = fade * fade; // quadratic falloff for smoother trail
+ col[ti * 3] = p.color.r * fade * .7;
+ col[ti * 3 + 1] = p.color.g * fade * .7;
+ col[ti * 3 + 2] = p.color.b * fade * .7;
+ }
+ }
+ sys.geo.attributes.position.needsUpdate = true;
+ sys.geo.attributes.color.needsUpdate = true;
+ }
+
+ // ── Update stream particles (flow between two windows) ──────────
+ function updateStream(sys, cxA, cyA, cxB, cyB, time) {
+ let ps = sys.particles;
+ let pos = sys.geo.attributes.position.array;
+ let col = sys.geo.attributes.color.array;
+ let tl = sys.trailLen, n = sys.count;
+ let midX = (cxA + cxB) / 2, midY = (cyA + cyB) / 2;
+ let pathDx = cxB - cxA, pathDy = cyB - cyA;
+ let pathLen = Math.sqrt(pathDx * pathDx + pathDy * pathDy);
+ // Normal to path
+ let normX = -pathDy / (pathLen || 1), normY = pathDx / (pathLen || 1);
+
+ for (let i = 0; i < n; i++) {
+ let p = ps[i];
+ // Project onto path to find where particle is
+ let relX = p.x - cxA, relY = p.y - cyA;
+ let proj = (relX * pathDx + relY * pathDy) / (pathLen * pathLen || 1);
+
+ // Flow along path direction with sine wave perpendicular offset
+ let flowAngle = Math.atan2(pathDy, pathDx);
+ let noiseVal = fbm(p.x * .003 + time * .1, p.y * .003 + time * .08, 3);
+
+ // Force along the path (oscillating direction)
+ let dir = Math.sin(time * .3 + i * .5) > 0 ? 1 : -1;
+ let fx = Math.cos(flowAngle) * .4 * dir;
+ let fy = Math.sin(flowAngle) * .4 * dir;
+
+ // Perpendicular wave force
+ fx += normX * noiseVal * .8;
+ fy += normY * noiseVal * .8;
+
+ // Pull back to path center
+ let pathX = cxA + pathDx * Math.max(0, Math.min(1, proj));
+ let pathY = cyA + pathDy * Math.max(0, Math.min(1, proj));
+ let perpDist = Math.sqrt((p.x - pathX) * (p.x - pathX) + (p.y - pathY) * (p.y - pathY));
+ if (perpDist > 80) {
+ fx += (pathX - p.x) * .01;
+ fy += (pathY - p.y) * .01;
+ }
+
+ // Pull back if too far past endpoints
+ if (proj < -.1) { fx += pathDx * .003; fy += pathDy * .003; }
+ if (proj > 1.1) { fx -= pathDx * .003; fy -= pathDy * .003; }
+
+ p.vx += fx * .12; p.vy += fy * .12;
+ p.vx *= .94; p.vy *= .94;
+ p.x += p.vx; p.y += p.vy;
+ p.z = Math.sin(time * .5 + p.phase + proj * 4) * 8;
+
+ p.trail.pop();
+ p.trail.unshift({ x: p.x, y: p.y, z: p.z });
+
+ let idx = i * (1 + tl);
+ pos[idx * 3] = p.x; pos[idx * 3 + 1] = p.y; pos[idx * 3 + 2] = p.z;
+ col[idx * 3] = p.color.r; col[idx * 3 + 1] = p.color.g; col[idx * 3 + 2] = p.color.b;
+
+ for (let t = 0; t < tl; t++) {
+ let ti = idx + 1 + t, tr = p.trail[t];
+ pos[ti * 3] = tr.x; pos[ti * 3 + 1] = tr.y; pos[ti * 3 + 2] = tr.z;
+ let fade = 1 - (t + 1) / (tl + 1); fade *= fade;
+ col[ti * 3] = p.color.r * fade * .65;
+ col[ti * 3 + 1] = p.color.g * fade * .65;
+ col[ti * 3 + 2] = p.color.b * fade * .65;
+ }
+ }
+ sys.geo.attributes.position.needsUpdate = true;
+ sys.geo.attributes.color.needsUpdate = true;
+ }
+
+ // ── Render ──────────────────────────────────────────────────────
+ function render() {
+ let time = gTime();
+ windowManager.update();
+ let f = .05;
+ so.x += (soT.x - so.x) * f; so.y += (soT.y - so.y) * f;
+ world.position.x = so.x; world.position.y = so.y;
+ let wins = windowManager.getWindows();
+
+ // Background drift
+ if (bgSystem) {
+ let bp = bgSystem.geo.attributes.position.array;
+ for (let i = 0; i < C.BG_PARTICLES; i++) {
+ let angle = fbm(bp[i * 3] * .0005 + time * .02, bp[i * 3 + 1] * .0005 + time * .015, 2) * PI2;
+ bp[i * 3] += Math.cos(angle) * .12;
+ bp[i * 3 + 1] += Math.sin(angle) * .1;
+ }
+ bgSystem.geo.attributes.position.needsUpdate = true;
+ }
+
+ // Per-window flow particles
+ for (let w = 0; w < winLayers.length && w < wins.length; w++) {
+ let L = winLayers[w], win = wins[w];
+ let cx = win.shape.x + win.shape.w * .5, cy = win.shape.y + win.shape.h * .5;
+ updateFlow(L.flow, cx, cy, time + w * 5, C.CENTER_PULL, C.WANDER_RADIUS,
+ C.FLOW_SCALE, C.FLOW_SPEED);
+ L.flow.mat.opacity = .85 + Math.sin(time * .8 + w) * .1;
+
+ // Soft connection lines (sparse, only close neighbors)
+ let ps = L.flow.particles;
+ let lv = [], step = Math.max(1, Math.floor(C.FLOW_COUNT / 40));
+ for (let a = 0; a < C.FLOW_COUNT; a += step) {
+ for (let b = a + step; b < C.FLOW_COUNT; b += step) {
+ let dx = ps[a].x - ps[b].x, dy = ps[a].y - ps[b].y;
+ let d = Math.sqrt(dx * dx + dy * dy);
+ if (d < 140) {
+ lv.push(ps[a].x, ps[a].y, ps[a].z, ps[b].x, ps[b].y, ps[b].z);
+ }
+ }
+ if (lv.length > 2400) break;
+ }
+ let lg = new T.BufferGeometry();
+ if (lv.length) lg.setAttribute('position', new T.BufferAttribute(new Float32Array(lv), 3));
+ L.connLine.geometry.dispose(); L.connLine.geometry = lg;
+ L.connLine.material.opacity = .1 + Math.sin(time * .6 + w) * .04;
+ }
+
+ // Cross-window streams
+ for (let s = 0; s < streamLayers.length; s++) {
+ let S = streamLayers[s];
+ if (S.idxA >= wins.length || S.idxB >= wins.length) continue;
+ let wA = wins[S.idxA], wB = wins[S.idxB];
+ let cxA = wA.shape.x + wA.shape.w * .5, cyA = wA.shape.y + wA.shape.h * .5;
+ let cxB = wB.shape.x + wB.shape.w * .5, cyB = wB.shape.y + wB.shape.h * .5;
+ updateStream(S.flow, cxA, cyA, cxB, cyB, time);
+ S.flow.mat.opacity = .8 + Math.sin(time + s) * .12;
+ }
+
+ renderer.render(scene, camera);
+ requestAnimationFrame(render);
+ }
+
+ function resize() {
+ let w = innerWidth, h = innerHeight;
+ camera = new T.OrthographicCamera(0, w, 0, h, -10000, 10000);
+ camera.updateProjectionMatrix(); renderer.setSize(w, h);
+ }
}
\ No newline at end of file