Skip to content

Instantly share code, notes, and snippets.

@withakay
Created February 17, 2026 20:22
Show Gist options
  • Select an option

  • Save withakay/1bdd9e7ee1c1e2953fd727e152641815 to your computer and use it in GitHub Desktop.

Select an option

Save withakay/1bdd9e7ee1c1e2953fd727e152641815 to your computer and use it in GitHub Desktop.
Network N — Interactive 3D Wireframe | Three.js + GSAP + CRT GLSL Shader
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Network N — Interactive 3D Wireframe</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0a0a1a;
overflow: hidden;
font-family: 'Segoe UI', system-ui, sans-serif;
color: #cceeff;
}
canvas { display: block; }
#info {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
text-align: center;
pointer-events: none;
opacity: 0;
font-size: 12px;
letter-spacing: 0.5px;
}
#info span { color: #bb66ff; }
/* ── Control Panel ── */
#controls {
position: absolute;
top: 16px;
right: 16px;
width: 250px;
max-height: calc(100vh - 32px);
overflow-y: auto;
background: rgba(6, 8, 20, 0.88);
border: 1px solid rgba(120, 60, 255, 0.18);
border-radius: 10px;
padding: 14px 16px;
backdrop-filter: blur(12px);
z-index: 100;
font-size: 12px;
transition: transform 0.25s ease;
opacity: 0;
}
#controls.collapsed {
transform: translateX(calc(100% + 16px));
}
#controls h3 {
margin: 0 0 10px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1.5px;
color: #448;
border-bottom: 1px solid rgba(120,60,255,0.1);
padding-bottom: 6px;
}
.ctrl-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.ctrl-row label {
flex: 0 0 auto;
margin-right: 8px;
color: #7799aa;
font-size: 11px;
white-space: nowrap;
}
.ctrl-row input[type="range"] {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 4px;
background: #1a2a3a;
border-radius: 2px;
outline: none;
}
.ctrl-row input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #7744cc;
cursor: pointer;
}
.ctrl-row .val {
width: 32px;
text-align: right;
font-size: 10px;
color: #556;
margin-left: 6px;
}
.ctrl-row input[type="color"] {
-webkit-appearance: none;
appearance: none;
width: 28px;
height: 20px;
border: 1px solid #224;
border-radius: 4px;
background: transparent;
cursor: pointer;
padding: 0;
}
.ctrl-row input[type="color"]::-webkit-color-swatch-wrapper { padding: 1px; }
.ctrl-row input[type="color"]::-webkit-color-swatch { border-radius: 3px; border: none; }
.ctrl-section { margin-bottom: 12px; }
.ctrl-section-title {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1px;
color: #335;
margin-bottom: 6px;
}
#toggle-btn {
position: absolute;
top: 16px;
right: 16px;
width: 32px;
height: 32px;
background: rgba(6, 8, 20, 0.85);
border: 1px solid rgba(120,60,255,0.18);
border-radius: 6px;
color: #7744cc;
font-size: 16px;
cursor: pointer;
z-index: 101;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.2s;
opacity: 0;
}
#toggle-btn:hover { background: rgba(120,60,255,0.12); }
.ctrl-row input[type="checkbox"] {
accent-color: #7744cc;
}
#controls::-webkit-scrollbar { width: 4px; }
#controls::-webkit-scrollbar-track { background: transparent; }
#controls::-webkit-scrollbar-thumb { background: #224; border-radius: 2px; }
/* ── GSAP Buttons ── */
.gsap-btn {
display: inline-block;
padding: 4px 10px;
margin: 2px;
background: rgba(120, 60, 255, 0.15);
border: 1px solid rgba(120, 60, 255, 0.3);
border-radius: 5px;
color: #aa88ee;
font-size: 10px;
cursor: pointer;
transition: all 0.2s;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.gsap-btn:hover {
background: rgba(120, 60, 255, 0.3);
color: #ddbbff;
}
.gsap-btn.active {
background: rgba(120, 60, 255, 0.4);
color: #fff;
border-color: rgba(120, 60, 255, 0.6);
}
.gsap-btns {
display: flex;
flex-wrap: wrap;
gap: 3px;
margin-bottom: 6px;
}
/* ── Presets ── */
.preset-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
margin-bottom: 6px;
}
.preset-btn {
padding: 5px 4px;
background: rgba(120, 60, 255, 0.10);
border: 1px solid rgba(120, 60, 255, 0.25);
border-radius: 5px;
color: #9977cc;
font-size: 9px;
cursor: pointer;
transition: all 0.2s;
text-align: center;
letter-spacing: 0.3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preset-btn:hover {
background: rgba(120, 60, 255, 0.25);
color: #ddbbff;
border-color: rgba(120, 60, 255, 0.5);
}
.preset-btn.active-preset {
background: rgba(120, 60, 255, 0.35);
color: #fff;
border-color: rgba(180, 100, 255, 0.6);
box-shadow: 0 0 6px rgba(120, 60, 255, 0.3);
}
.preset-btn.user-slot {
border-style: dashed;
}
.preset-btn.user-slot.has-data {
border-style: solid;
color: #bb99ee;
}
.preset-actions {
display: flex;
gap: 4px;
margin-top: 4px;
}
.preset-actions button {
flex: 1;
padding: 4px;
background: rgba(120, 60, 255, 0.12);
border: 1px solid rgba(120, 60, 255, 0.25);
border-radius: 4px;
color: #9977cc;
font-size: 9px;
cursor: pointer;
transition: all 0.2s;
}
.preset-actions button:hover {
background: rgba(120, 60, 255, 0.3);
color: #ddbbff;
}
/* ── Timeline Scrubber ── */
#scrubber-bar {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
width: 60%;
max-width: 600px;
z-index: 100;
display: none;
align-items: center;
gap: 10px;
background: rgba(6, 8, 20, 0.85);
border: 1px solid rgba(120,60,255,0.2);
border-radius: 8px;
padding: 8px 14px;
backdrop-filter: blur(12px);
}
#scrubber-bar label {
font-size: 10px;
color: #7799aa;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
}
#scrubber {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 4px;
background: #1a2a3a;
border-radius: 2px;
outline: none;
}
#scrubber::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: #cc66ee;
cursor: pointer;
}
#scrub-time {
font-size: 10px;
color: #556;
width: 34px;
text-align: right;
}
#scrub-play {
background: none;
border: 1px solid rgba(120,60,255,0.3);
border-radius: 4px;
color: #aa88ee;
font-size: 14px;
cursor: pointer;
padding: 2px 6px;
}
#scrub-play:hover { background: rgba(120,60,255,0.2); }
</style>
</head>
<body>
<div id="info">
Drag to orbit &middot; Scroll to zoom &middot; <span>Hover</span> to push nodes &middot; <span>Click</span> to pulse &middot; <span>Shift+Drag</span> to move nodes
</div>
<button id="toggle-btn" title="Toggle controls">&#9881;</button>
<div id="controls">
<h3>Network Controls</h3>
<div class="ctrl-section">
<div class="ctrl-section-title">Geometry</div>
<div class="ctrl-row">
<label>Node Size</label>
<input type="range" id="nodeSize" min="0.04" max="0.5" step="0.01" value="0.04">
<span class="val" id="nodeSizeVal">0.04</span>
</div>
<div class="ctrl-row">
<label>Edge Width</label>
<input type="range" id="edgeWidth" min="0.005" max="0.12" step="0.002" value="0.020">
<span class="val" id="edgeWidthVal">0.020</span>
</div>
</div>
<div class="ctrl-section">
<div class="ctrl-section-title">Appearance</div>
<div class="ctrl-row">
<label>Bloom</label>
<input type="range" id="bloomStrength" min="0" max="3" step="0.05" value="2.5">
<span class="val" id="bloomStrengthVal">2.50</span>
</div>
<div class="ctrl-row">
<label>Edge Opacity</label>
<input type="range" id="edgeOpacity" min="0.05" max="1" step="0.05" value="0.10">
<span class="val" id="edgeOpacityVal">0.10</span>
</div>
<div class="ctrl-row">
<label>Node Color</label>
<input type="color" id="nodeColor" value="#8855cc">
</div>
<div class="ctrl-row">
<label>Edge Color</label>
<input type="color" id="edgeColor" value="#7744bb">
</div>
<div class="ctrl-row">
<label>Pulse Color</label>
<input type="color" id="pulseColor" value="#cc66ee">
</div>
</div>
<div class="ctrl-section">
<div class="ctrl-section-title">Animation</div>
<div class="ctrl-row">
<label>Rotation</label>
<input type="range" id="rotSpeed" min="0" max="3" step="0.05" value="0.70">
<span class="val" id="rotSpeedVal">0.70</span>
</div>
<div class="ctrl-row">
<label>Pulse Rate</label>
<input type="range" id="pulseRate" min="0" max="8" step="0.1" value="0.0">
<span class="val" id="pulseRateVal">0.0</span>
</div>
<div class="ctrl-row">
<label>Pulse Speed</label>
<input type="range" id="pulseSpeed" min="0.1" max="4" step="0.1" value="0.1">
<span class="val" id="pulseSpeedVal">0.1</span>
</div>
<div class="ctrl-row">
<label>Cascade</label>
<input type="range" id="cascadeChance" min="0" max="1" step="0.05" value="0.15">
<span class="val" id="cascadeChanceVal">0.15</span>
</div>
<div class="ctrl-row">
<label>Auto-rotate</label>
<input type="checkbox" id="autoRotate" checked>
</div>
</div>
<div class="ctrl-section">
<div class="ctrl-section-title">Physics</div>
<div class="ctrl-row">
<label>Repulsion</label>
<input type="range" id="repulsion" min="0" max="30" step="0.5" value="26.5">
<span class="val" id="repulsionVal">26.5</span>
</div>
<div class="ctrl-row">
<label>Radius</label>
<input type="range" id="repulsionRadius" min="0.5" max="8" step="0.1" value="4.4">
<span class="val" id="repulsionRadiusVal">4.4</span>
</div>
<div class="ctrl-row">
<label>Spring</label>
<input type="range" id="springK" min="1" max="80" step="1" value="70">
<span class="val" id="springKVal">70</span>
</div>
<div class="ctrl-row">
<label>Damping</label>
<input type="range" id="damping" min="0.5" max="15" step="0.1" value="6.0">
<span class="val" id="dampingVal">6.0</span>
</div>
<div class="ctrl-row">
<label>Jiggle</label>
<input type="range" id="jiggle" min="0" max="20" step="0.5" value="17.0">
<span class="val" id="jiggleVal">17.0</span>
</div>
</div>
<div class="ctrl-section">
<div class="ctrl-section-title">Particles</div>
<div class="ctrl-row">
<label>Count</label>
<input type="range" id="particleCount" min="0" max="200" step="5" value="80">
<span class="val" id="particleCountVal">80</span>
</div>
<div class="ctrl-row">
<label>Speed</label>
<input type="range" id="particleSpeed" min="0.2" max="8" step="0.1" value="0.5">
<span class="val" id="particleSpeedVal">0.5</span>
</div>
<div class="ctrl-row">
<label>Size</label>
<input type="range" id="particleSize" min="0.02" max="0.2" step="0.005" value="0.02">
<span class="val" id="particleSizeVal">0.020</span>
</div>
<div class="ctrl-row">
<label>Color</label>
<input type="color" id="particleColor" value="#aa55ff">
</div>
</div>
<div class="ctrl-section">
<div class="ctrl-section-title">CRT Shader</div>
<div class="ctrl-row">
<label>Enabled</label>
<input type="checkbox" id="crtEnabled" checked>
</div>
<div class="ctrl-row">
<label>Scanlines</label>
<input type="range" id="crtScanlines" min="0" max="0.6" step="0.01" value="0.18">
<span class="val" id="crtScanlinesVal">0.18</span>
</div>
<div class="ctrl-row">
<label>Line Count</label>
<input type="range" id="crtScanlineCount" min="50" max="800" step="10" value="300">
<span class="val" id="crtScanlineCountVal">300</span>
</div>
<div class="ctrl-row">
<label>Chroma</label>
<input type="range" id="crtChroma" min="0" max="6" step="0.1" value="1.5">
<span class="val" id="crtChromaVal">1.5</span>
</div>
<div class="ctrl-row">
<label>Curvature</label>
<input type="range" id="crtCurvature" min="0" max="5" step="0.1" value="3.0">
<span class="val" id="crtCurvatureVal">3.0</span>
</div>
<div class="ctrl-row">
<label>Vignette</label>
<input type="range" id="crtVignette" min="0" max="1" step="0.01" value="0.35">
<span class="val" id="crtVignetteVal">0.35</span>
</div>
<div class="ctrl-row">
<label>Flicker</label>
<input type="range" id="crtFlicker" min="0" max="0.15" step="0.005" value="0.03">
<span class="val" id="crtFlickerVal">0.030</span>
</div>
<div class="ctrl-row">
<label>Noise</label>
<input type="range" id="crtNoise" min="0" max="0.3" step="0.005" value="0.06">
<span class="val" id="crtNoiseVal">0.060</span>
</div>
<div class="ctrl-row">
<label>Glow</label>
<input type="range" id="crtGlow" min="0" max="0.5" step="0.01" value="0.15">
<span class="val" id="crtGlowVal">0.15</span>
</div>
<div class="ctrl-row">
<label>Brightness</label>
<input type="range" id="crtBrightness" min="-0.5" max="0.5" step="0.01" value="0.0">
<span class="val" id="crtBrightnessVal">0.00</span>
</div>
<div class="ctrl-row">
<label>Contrast</label>
<input type="range" id="crtContrast" min="0.2" max="3.0" step="0.05" value="1.0">
<span class="val" id="crtContrastVal">1.00</span>
</div>
<div class="ctrl-row">
<label>Saturation</label>
<input type="range" id="crtSaturation" min="0.0" max="3.0" step="0.05" value="1.0">
<span class="val" id="crtSaturationVal">1.00</span>
</div>
</div>
<div class="ctrl-section">
<div class="ctrl-section-title">Presets</div>
<div class="preset-grid" id="preset-grid"></div>
<div class="preset-actions">
<button id="preset-save">Save to slot</button>
<button id="preset-delete">Delete slot</button>
<button id="preset-reset">Reset all</button>
</div>
</div>
<div class="ctrl-section">
<div class="ctrl-section-title">GSAP</div>
<div class="gsap-btns">
<button class="gsap-btn" id="btn-replay-intro">Replay Intro</button>
<button class="gsap-btn" id="btn-flythrough">Fly-through</button>
<button class="gsap-btn" id="btn-explode">Explode</button>
</div>
<div class="gsap-btns">
<button class="gsap-btn" id="btn-morph-E">Morph: E</button>
<button class="gsap-btn" id="btn-morph-T">Morph: T</button>
<button class="gsap-btn" id="btn-morph-N">Morph: N</button>
</div>
<div class="gsap-btns">
<button class="gsap-btn" id="btn-wave">Wave</button>
<button class="gsap-btn" id="btn-heartbeat">Heartbeat</button>
<button class="gsap-btn" id="btn-spiral">Spiral</button>
</div>
<div class="ctrl-row" style="margin-top:6px">
<label>Drag Nodes</label>
<input type="checkbox" id="dragMode">
</div>
</div>
</div>
<div id="scrubber-bar">
<button id="scrub-play">&#9654;</button>
<label>Timeline</label>
<input type="range" id="scrubber" min="0" max="1" step="0.001" value="0">
<span id="scrub-time">0.0s</span>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.162.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.162.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
// ─── Retro CRT Shader ───────────────────────────────────────────
const RetroCRTShader = {
uniforms: {
tDiffuse: { value: null },
uTime: { value: 0.0 },
uScanlineIntensity: { value: 0.18 },
uScanlineCount: { value: 300.0 },
uChromaShift: { value: 1.5 },
uCurvature: { value: 3.0 },
uVignette: { value: 0.35 },
uFlicker: { value: 0.03 },
uNoise: { value: 0.06 },
uGlow: { value: 0.15 },
uBrightness: { value: 0.0 },
uContrast: { value: 1.0 },
uSaturation: { value: 1.0 },
uResolution: { value: new THREE.Vector2(innerWidth, innerHeight) },
},
vertexShader: /* glsl */`
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: /* glsl */`
uniform sampler2D tDiffuse;
uniform float uTime;
uniform float uScanlineIntensity;
uniform float uScanlineCount;
uniform float uChromaShift;
uniform float uCurvature;
uniform float uVignette;
uniform float uFlicker;
uniform float uNoise;
uniform float uGlow;
uniform float uBrightness;
uniform float uContrast;
uniform float uSaturation;
uniform vec2 uResolution;
varying vec2 vUv;
// Barrel distortion for screen curvature
vec2 curveUV(vec2 uv, float amount) {
uv = uv * 2.0 - 1.0;
vec2 offset = abs(uv.yx) / vec2(amount, amount);
uv = uv + uv * offset * offset;
uv = uv * 0.5 + 0.5;
return uv;
}
// Pseudo-random noise
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
void main() {
// Apply curvature
vec2 uv = uCurvature > 0.0 ? curveUV(vUv, 6.0 - uCurvature) : vUv;
// Discard outside curved screen
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) {
gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
return;
}
// Chromatic aberration - offset R and B channels
float chromaAmount = uChromaShift / uResolution.x;
float r = texture2D(tDiffuse, vec2(uv.x + chromaAmount, uv.y)).r;
float g = texture2D(tDiffuse, uv).g;
float b = texture2D(tDiffuse, vec2(uv.x - chromaAmount, uv.y)).b;
vec3 color = vec3(r, g, b);
// Scanlines
float scanline = sin(uv.y * uScanlineCount * 3.14159) * 0.5 + 0.5;
scanline = pow(scanline, 1.5);
color *= 1.0 - uScanlineIntensity * (1.0 - scanline);
// Horizontal sub-pixel pattern (RGB stripe)
float px = fract(uv.x * uResolution.x / 3.0);
vec3 subPixel = vec3(
smoothstep(0.0, 0.33, px) - smoothstep(0.33, 0.66, px),
smoothstep(0.33, 0.66, px) - smoothstep(0.66, 1.0, px),
smoothstep(0.66, 1.0, px)
);
color = mix(color, color * (subPixel * 0.5 + 0.5), uGlow);
// Screen flicker (subtle brightness oscillation)
float flicker = 1.0 - uFlicker * sin(uTime * 8.0 + sin(uTime * 3.0) * 5.0);
color *= flicker;
// Static noise
float noise = hash(uv * uResolution + fract(uTime * 100.0)) * uNoise;
color += vec3(noise) - uNoise * 0.5;
// Interlace: every other frame, dim alternating lines
float interlace = mod(floor(uv.y * uResolution.y) + floor(uTime * 30.0), 2.0);
color *= 1.0 - 0.02 * interlace;
// Vignette
vec2 vig = uv * (1.0 - uv);
float vigAmount = pow(vig.x * vig.y * 16.0, uVignette);
color *= vigAmount;
// Slight phosphor glow - boost bright areas
float luma = dot(color, vec3(0.299, 0.587, 0.114));
color += color * uGlow * smoothstep(0.4, 1.0, luma);
// Brightness
color += uBrightness;
// Contrast (pivot around 0.5)
color = (color - 0.5) * uContrast + 0.5;
// Saturation
float grey = dot(color, vec3(0.299, 0.587, 0.114));
color = mix(vec3(grey), color, uSaturation);
gl_FragColor = vec4(color, 1.0);
}
`,
};
// ─── Reactive parameters ─────────────────────────────────────────
const params = {
nodeSize: 0.04,
edgeWidth: 0.020,
bloomStrength: 2.5,
edgeOpacity: 0.10,
rotSpeed: 0.70,
pulseRate: 0.0,
pulseSpeed: 0.1,
cascadeChance: 0.15,
autoRotate: true,
nodeColor: new THREE.Color(0x8855cc),
edgeColor: new THREE.Color(0x7744bb),
pulseColor: new THREE.Color(0xcc66ee),
repulsion: 26.5,
repulsionRadius: 4.4,
springK: 70,
damping: 6.0,
jiggle: 17.0,
particleCount: 80,
particleSpeed: 0.5,
particleSize: 0.02,
particleColor: new THREE.Color(0xaa55ff),
dragMode: false,
// CRT shader
crtEnabled: true,
crtScanlines: 0.18,
crtScanlineCount: 300,
crtChroma: 1.5,
crtCurvature: 3.0,
crtVignette: 0.35,
crtFlicker: 0.03,
crtNoise: 0.06,
crtGlow: 0.15,
crtBrightness: 0.0,
crtContrast: 1.0,
crtSaturation: 1.0,
};
// ─── Toggle panel ────────────────────────────────────────────────
const panel = document.getElementById('controls');
const toggleBtn = document.getElementById('toggle-btn');
toggleBtn.addEventListener('click', () => panel.classList.toggle('collapsed'));
// ─── Bind sliders ────────────────────────────────────────────────
function bindSlider(id, key, fmt) {
const el = document.getElementById(id);
const valEl = document.getElementById(id + 'Val');
el.addEventListener('input', () => {
params[key] = parseFloat(el.value);
if (valEl) valEl.textContent = fmt ? fmt(params[key]) : params[key].toFixed(2);
});
}
bindSlider('nodeSize', 'nodeSize');
bindSlider('edgeWidth', 'edgeWidth', v => v.toFixed(3));
bindSlider('bloomStrength', 'bloomStrength');
bindSlider('edgeOpacity', 'edgeOpacity');
bindSlider('rotSpeed', 'rotSpeed');
bindSlider('pulseRate', 'pulseRate', v => v.toFixed(1));
bindSlider('pulseSpeed', 'pulseSpeed', v => v.toFixed(1));
bindSlider('cascadeChance', 'cascadeChance');
bindSlider('repulsion', 'repulsion', v => v.toFixed(1));
bindSlider('repulsionRadius', 'repulsionRadius', v => v.toFixed(1));
bindSlider('springK', 'springK', v => v.toFixed(0));
bindSlider('damping', 'damping', v => v.toFixed(1));
bindSlider('jiggle', 'jiggle', v => v.toFixed(1));
bindSlider('particleCount', 'particleCount', v => v.toFixed(0));
bindSlider('particleSpeed', 'particleSpeed', v => v.toFixed(1));
bindSlider('particleSize', 'particleSize', v => v.toFixed(3));
document.getElementById('autoRotate').addEventListener('change', e => params.autoRotate = e.target.checked);
document.getElementById('nodeColor').addEventListener('input', e => params.nodeColor.set(e.target.value));
document.getElementById('edgeColor').addEventListener('input', e => params.edgeColor.set(e.target.value));
document.getElementById('pulseColor').addEventListener('input', e => params.pulseColor.set(e.target.value));
document.getElementById('particleColor').addEventListener('input', e => params.particleColor.set(e.target.value));
document.getElementById('dragMode').addEventListener('change', e => params.dragMode = e.target.checked);
// CRT bindings
document.getElementById('crtEnabled').addEventListener('change', e => { params.crtEnabled = e.target.checked; });
bindSlider('crtScanlines', 'crtScanlines');
bindSlider('crtScanlineCount', 'crtScanlineCount', v => v.toFixed(0));
bindSlider('crtChroma', 'crtChroma', v => v.toFixed(1));
bindSlider('crtCurvature', 'crtCurvature', v => v.toFixed(1));
bindSlider('crtVignette', 'crtVignette');
bindSlider('crtFlicker', 'crtFlicker', v => v.toFixed(3));
bindSlider('crtNoise', 'crtNoise', v => v.toFixed(3));
bindSlider('crtGlow', 'crtGlow');
bindSlider('crtBrightness', 'crtBrightness');
bindSlider('crtContrast', 'crtContrast');
bindSlider('crtSaturation', 'crtSaturation');
// ─── Presets ─────────────────────────────────────────────────────
const STORAGE_KEY = 'net3d_presets';
// Serializable param keys (excludes THREE.Color objects which need special handling)
const SCALAR_KEYS = [
'nodeSize','edgeWidth','bloomStrength','edgeOpacity','rotSpeed',
'pulseRate','pulseSpeed','cascadeChance','autoRotate',
'repulsion','repulsionRadius','springK','damping','jiggle',
'particleCount','particleSpeed','particleSize',
'crtEnabled','crtScanlines','crtScanlineCount','crtChroma',
'crtCurvature','crtVignette','crtFlicker','crtNoise','crtGlow',
'crtBrightness','crtContrast','crtSaturation',
];
const COLOR_KEYS = ['nodeColor','edgeColor','pulseColor','particleColor'];
function serializeParams() {
const o = {};
for (const k of SCALAR_KEYS) o[k] = params[k];
for (const k of COLOR_KEYS) o[k] = '#' + params[k].getHexString();
return o;
}
function applyParams(data) {
for (const k of SCALAR_KEYS) {
if (data[k] !== undefined) params[k] = data[k];
}
for (const k of COLOR_KEYS) {
if (data[k]) params[k].set(data[k]);
}
syncUI();
}
function syncUI() {
// Sync all sliders and checkboxes with current params
const sliderMap = {
nodeSize: v=>v.toFixed(2), edgeWidth: v=>v.toFixed(3),
bloomStrength: v=>v.toFixed(2), edgeOpacity: v=>v.toFixed(2),
rotSpeed: v=>v.toFixed(2), pulseRate: v=>v.toFixed(1),
pulseSpeed: v=>v.toFixed(1), cascadeChance: v=>v.toFixed(2),
repulsion: v=>v.toFixed(1), repulsionRadius: v=>v.toFixed(1),
springK: v=>v.toFixed(0), damping: v=>v.toFixed(1),
jiggle: v=>v.toFixed(1), particleCount: v=>v.toFixed(0),
particleSpeed: v=>v.toFixed(1), particleSize: v=>v.toFixed(3),
crtScanlines: v=>v.toFixed(2), crtScanlineCount: v=>v.toFixed(0),
crtChroma: v=>v.toFixed(1), crtCurvature: v=>v.toFixed(1),
crtVignette: v=>v.toFixed(2), crtFlicker: v=>v.toFixed(3),
crtNoise: v=>v.toFixed(3), crtGlow: v=>v.toFixed(2),
crtBrightness: v=>v.toFixed(2), crtContrast: v=>v.toFixed(2),
crtSaturation: v=>v.toFixed(2),
};
for (const [id, fmt] of Object.entries(sliderMap)) {
const el = document.getElementById(id);
const valEl = document.getElementById(id + 'Val');
if (el) el.value = params[id];
if (valEl) valEl.textContent = fmt(params[id]);
}
const checks = { autoRotate: 'autoRotate', crtEnabled: 'crtEnabled' };
for (const [id, key] of Object.entries(checks)) {
const el = document.getElementById(id);
if (el) el.checked = params[key];
}
const colors = { nodeColor: 'nodeColor', edgeColor: 'edgeColor', pulseColor: 'pulseColor', particleColor: 'particleColor' };
for (const [id, key] of Object.entries(colors)) {
const el = document.getElementById(id);
if (el) el.value = '#' + params[key].getHexString();
}
}
// 8 built-in presets
const builtinPresets = [
{
name: 'Default',
data: serializeParams(), // current defaults
},
{
name: 'Neon Blaze',
data: { ...serializeParams(), bloomStrength: 3.0, crtScanlines: 0.4, crtChroma: 4.0, crtGlow: 0.4,
nodeColor: '#ff2288', edgeColor: '#ff4466', pulseColor: '#ffaa00',
crtBrightness: 0.05, crtContrast: 1.5, crtSaturation: 1.8 },
},
{
name: 'Ghost Wire',
data: { ...serializeParams(), nodeSize: 0.06, edgeWidth: 0.008, bloomStrength: 1.8, edgeOpacity: 0.06,
nodeColor: '#44ffcc', edgeColor: '#224444', pulseColor: '#88ffee', particleColor: '#33ddaa',
crtScanlines: 0.05, crtChroma: 0.5, crtCurvature: 1.0, crtVignette: 0.6,
crtBrightness: -0.08, crtContrast: 1.2, crtSaturation: 0.7 },
},
{
name: 'Amber Terminal',
data: { ...serializeParams(), bloomStrength: 2.0, nodeColor: '#ffaa22', edgeColor: '#664400',
pulseColor: '#ffcc44', particleColor: '#ff8800',
crtScanlines: 0.35, crtScanlineCount: 400, crtChroma: 0.8, crtCurvature: 4.0,
crtVignette: 0.5, crtFlicker: 0.06, crtNoise: 0.1, crtGlow: 0.3,
crtBrightness: -0.05, crtContrast: 1.3, crtSaturation: 0.5 },
},
{
name: 'Deep Space',
data: { ...serializeParams(), nodeSize: 0.08, edgeWidth: 0.01, bloomStrength: 2.8, edgeOpacity: 0.08,
nodeColor: '#4466ff', edgeColor: '#1122aa', pulseColor: '#66aaff', particleColor: '#3355ff',
rotSpeed: 0.3, crtScanlines: 0.1, crtChroma: 2.0, crtCurvature: 2.0,
crtVignette: 0.7, crtBrightness: -0.12, crtContrast: 1.4, crtSaturation: 1.2 },
},
{
name: 'Cyberpunk',
data: { ...serializeParams(), bloomStrength: 2.5, nodeColor: '#ff00ff', edgeColor: '#00ffff',
pulseColor: '#ffff00', particleColor: '#ff44ff',
crtScanlines: 0.3, crtScanlineCount: 250, crtChroma: 3.5, crtCurvature: 2.5,
crtGlow: 0.25, crtNoise: 0.04, crtBrightness: 0.08, crtContrast: 1.6, crtSaturation: 2.0 },
},
{
name: 'Clean',
data: { ...serializeParams(), bloomStrength: 1.0, edgeOpacity: 0.4,
nodeColor: '#ffffff', edgeColor: '#667788', pulseColor: '#aaccff', particleColor: '#8899bb',
crtEnabled: false, crtScanlines: 0, crtChroma: 0, crtCurvature: 0, crtVignette: 0,
crtFlicker: 0, crtNoise: 0, crtGlow: 0, crtBrightness: 0, crtContrast: 1, crtSaturation: 1 },
},
{
name: 'Matrix',
data: { ...serializeParams(), bloomStrength: 2.2, nodeColor: '#00ff44', edgeColor: '#004411',
pulseColor: '#44ff88', particleColor: '#00cc33',
crtScanlines: 0.25, crtScanlineCount: 500, crtChroma: 1.0, crtCurvature: 3.5,
crtVignette: 0.45, crtFlicker: 0.04, crtNoise: 0.08, crtGlow: 0.2,
crtBrightness: -0.05, crtContrast: 1.5, crtSaturation: 0.3, pulseRate: 3, pulseSpeed: 2, cascadeChance: 0.6 },
},
];
// Load user presets from localStorage
function loadUserPresets() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [{},{},{},{},{},{},{},{}];
} catch { return [{},{},{},{},{},{},{},{}]; }
}
function saveUserPresets(slots) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(slots));
}
let userSlots = loadUserPresets();
let activePresetIdx = 0;
let selectingSlot = null; // 'save' or 'delete' when waiting for user to pick a slot
const presetGrid = document.getElementById('preset-grid');
const presetSaveBtn = document.getElementById('preset-save');
const presetDeleteBtn = document.getElementById('preset-delete');
const presetResetBtn = document.getElementById('preset-reset');
function renderPresetGrid() {
presetGrid.innerHTML = '';
// 8 built-in
builtinPresets.forEach((p, i) => {
const btn = document.createElement('button');
btn.className = 'preset-btn' + (activePresetIdx === i ? ' active-preset' : '');
btn.textContent = p.name;
btn.title = p.name;
btn.addEventListener('click', () => {
if (selectingSlot) { selectingSlot = null; renderPresetGrid(); return; }
activePresetIdx = i;
applyParams(p.data);
renderPresetGrid();
});
presetGrid.appendChild(btn);
});
// 8 user slots
userSlots.forEach((slot, i) => {
const idx = builtinPresets.length + i;
const hasData = slot && slot.name;
const btn = document.createElement('button');
btn.className = 'preset-btn user-slot' + (hasData ? ' has-data' : '') + (activePresetIdx === idx ? ' active-preset' : '');
btn.textContent = hasData ? slot.name : `Slot ${i + 1}`;
btn.title = hasData ? slot.name : 'Empty slot — save to fill';
if (selectingSlot === 'save') {
btn.style.borderColor = 'rgba(100, 255, 100, 0.5)';
btn.style.color = '#88ff88';
btn.addEventListener('click', () => {
const name = prompt('Preset name:', hasData ? slot.name : `Custom ${i+1}`);
if (name === null) { selectingSlot = null; renderPresetGrid(); return; }
userSlots[i] = { name, data: serializeParams() };
saveUserPresets(userSlots);
activePresetIdx = idx;
selectingSlot = null;
renderPresetGrid();
});
} else if (selectingSlot === 'delete') {
if (hasData) {
btn.style.borderColor = 'rgba(255, 80, 80, 0.5)';
btn.style.color = '#ff6666';
}
btn.addEventListener('click', () => {
if (hasData) {
userSlots[i] = {};
saveUserPresets(userSlots);
if (activePresetIdx === idx) activePresetIdx = 0;
}
selectingSlot = null;
renderPresetGrid();
});
} else {
btn.addEventListener('click', () => {
if (hasData) {
activePresetIdx = idx;
applyParams(slot.data);
renderPresetGrid();
}
});
}
presetGrid.appendChild(btn);
});
}
presetSaveBtn.addEventListener('click', () => {
selectingSlot = selectingSlot === 'save' ? null : 'save';
renderPresetGrid();
});
presetDeleteBtn.addEventListener('click', () => {
selectingSlot = selectingSlot === 'delete' ? null : 'delete';
renderPresetGrid();
});
presetResetBtn.addEventListener('click', () => {
if (!confirm('Reset all user presets?')) return;
userSlots = [{},{},{},{},{},{},{},{}];
saveUserPresets(userSlots);
activePresetIdx = 0;
applyParams(builtinPresets[0].data);
renderPresetGrid();
});
renderPresetGrid();
// ─── Scene ────────────────────────────────────────────────────────
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x0a0a1a, 0.008);
const camera = new THREE.PerspectiveCamera(55, innerWidth / innerHeight, 0.1, 500);
camera.position.set(0, 0, 40); // Start far for intro zoom
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
document.body.appendChild(renderer.domElement);
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), params.bloomStrength, 0.35, 0.25);
composer.addPass(bloom);
const crtPass = new ShaderPass(RetroCRTShader);
crtPass.enabled = params.crtEnabled;
composer.addPass(crtPass);
const orbitControls = new OrbitControls(camera, renderer.domElement);
orbitControls.enableDamping = true;
orbitControls.dampingFactor = 0.06;
orbitControls.minDistance = 6;
orbitControls.maxDistance = 50;
orbitControls.autoRotate = true;
orbitControls.autoRotateSpeed = params.rotSpeed;
// ─── Build the 3D letter N ───────────────────────────────────────
const HEIGHT = 9, WIDTH = 5.5, DEPTH = 5.0;
const Z_LAYERS = 5, SEGS_VERT = 7, SEGS_DIAG = 9;
// Shape generators: return arrays of {x,y,z} for each node index
function shapeN() {
const positions = [];
for (let L = 0; L < Z_LAYERS; L++) {
const zt = L / (Z_LAYERS - 1);
const z = -DEPTH / 2 + zt * DEPTH;
const jx = Math.sin(zt * Math.PI * 3) * 0.25;
const jy = Math.cos(zt * Math.PI * 2) * 0.2;
for (let i = 0; i <= SEGS_VERT; i++) {
const t = i / SEGS_VERT;
positions.push({ x: -WIDTH / 2 + jx, y: -HEIGHT / 2 + t * HEIGHT + jy, z });
}
for (let i = 1; i < SEGS_DIAG; i++) {
const t = i / SEGS_DIAG;
positions.push({ x: -WIDTH / 2 + t * WIDTH + jx, y: HEIGHT / 2 - t * HEIGHT + jy, z });
}
for (let i = 0; i <= SEGS_VERT; i++) {
const t = i / SEGS_VERT;
positions.push({ x: WIDTH / 2 + jx, y: -HEIGHT / 2 + t * HEIGHT + jy, z });
}
}
return positions;
}
function shapeE() {
const positions = [];
const W = WIDTH, H = HEIGHT, D = DEPTH;
for (let L = 0; L < Z_LAYERS; L++) {
const zt = L / (Z_LAYERS - 1);
const z = -D / 2 + zt * D;
// Left vertical (same count as SEGS_VERT+1 = 8)
for (let i = 0; i <= SEGS_VERT; i++) {
const t = i / SEGS_VERT;
positions.push({ x: -W / 2, y: -H / 2 + t * H, z });
}
// Top horizontal bar + middle horizontal (SEGS_DIAG-1 = 8 nodes)
for (let i = 1; i < SEGS_DIAG; i++) {
const t = i / SEGS_DIAG;
// Alternate between top, middle, bottom bars
let y;
if (i <= 3) y = H / 2; // top bar
else if (i <= 5) y = 0; // middle bar
else y = -H / 2; // bottom bar
positions.push({ x: -W / 2 + t * W, y, z });
}
// Right "vertical" — for E these bunch at the bar tips
for (let i = 0; i <= SEGS_VERT; i++) {
const t = i / SEGS_VERT;
const barY = t < 0.33 ? -H / 2 : t < 0.66 ? 0 : H / 2;
positions.push({ x: W / 2 * 0.7, y: barY + (t % 0.33) * H * 0.3, z });
}
}
return positions;
}
function shapeT() {
const positions = [];
const W = WIDTH, H = HEIGHT, D = DEPTH;
for (let L = 0; L < Z_LAYERS; L++) {
const zt = L / (Z_LAYERS - 1);
const z = -D / 2 + zt * D;
// Left part becomes top-left of T crossbar
for (let i = 0; i <= SEGS_VERT; i++) {
const t = i / SEGS_VERT;
// Spread across top bar
positions.push({ x: -W / 2 + t * W, y: H / 2, z });
}
// Diagonal becomes the vertical stem
for (let i = 1; i < SEGS_DIAG; i++) {
const t = i / SEGS_DIAG;
positions.push({ x: 0, y: H / 2 - t * H, z });
}
// Right part becomes top-right / bottom stem area
for (let i = 0; i <= SEGS_VERT; i++) {
const t = i / SEGS_VERT;
positions.push({ x: t * 0.3, y: -H / 2 + t * H * 0.3, z });
}
}
return positions;
}
function makeNodes() {
const nodes = [];
let id = 0;
const add = (x, y, z, group, layer) => {
nodes.push({ id: id++, pos: new THREE.Vector3(x, y, z), group, layer });
};
for (let L = 0; L < Z_LAYERS; L++) {
const zt = L / (Z_LAYERS - 1);
const z = -DEPTH / 2 + zt * DEPTH;
const jx = Math.sin(zt * Math.PI * 3) * 0.25;
const jy = Math.cos(zt * Math.PI * 2) * 0.2;
for (let i = 0; i <= SEGS_VERT; i++) {
const t = i / SEGS_VERT;
add(-WIDTH / 2 + jx, -HEIGHT / 2 + t * HEIGHT + jy, z, 'left', L);
}
for (let i = 1; i < SEGS_DIAG; i++) {
const t = i / SEGS_DIAG;
add(-WIDTH / 2 + t * WIDTH + jx, HEIGHT / 2 - t * HEIGHT + jy, z, 'diagonal', L);
}
for (let i = 0; i <= SEGS_VERT; i++) {
const t = i / SEGS_VERT;
add(WIDTH / 2 + jx, -HEIGHT / 2 + t * HEIGHT + jy, z, 'right', L);
}
}
const structuralCount = nodes.length;
const rng = (seed) => { let s = seed; return () => { s = (s * 16807) % 2147483647; return s / 2147483647; }; };
const rand = rng(42);
for (let i = 0; i < 30; i++) {
for (let attempt = 0; attempt < 20; attempt++) {
const x = (rand() - 0.5) * WIDTH;
const y = (rand() - 0.5) * HEIGHT;
const z = (rand() - 0.5) * DEPTH;
const leftDist = Math.abs(x - (-WIDTH / 2));
const rightDist = Math.abs(x - (WIDTH / 2));
const diagY = HEIGHT / 2 - ((x + WIDTH / 2) / WIDTH) * HEIGHT;
const diagDist = Math.abs(y - diagY);
if (leftDist < 1.2 || rightDist < 1.2 || diagDist < 1.2) {
add(x, y, z, 'fill', -1);
break;
}
}
}
return { nodes, structuralCount };
}
function makeEdges(nodes) {
const edges = [];
const seen = new Set();
const addEdge = (a, b) => {
if (a === b) return;
const key = `${Math.min(a, b)}-${Math.max(a, b)}`;
if (seen.has(key)) return;
seen.add(key);
edges.push([a, b]);
};
for (let L = 0; L < Z_LAYERS; L++) {
const layerNodes = nodes.filter(n => n.layer === L);
const left = layerNodes.filter(n => n.group === 'left');
const diag = layerNodes.filter(n => n.group === 'diagonal');
const right = layerNodes.filter(n => n.group === 'right');
const seq = (arr) => { for (let i = 0; i < arr.length - 1; i++) addEdge(arr[i].id, arr[i + 1].id); };
seq(left); seq(right);
seq([left[left.length - 1], ...diag, right[0]]);
}
for (let L = 0; L < Z_LAYERS - 1; L++) {
const grouped = (layer, group) => nodes.filter(n => n.layer === layer && n.group === group);
['left', 'diagonal', 'right'].forEach(g => {
const curr = grouped(L, g);
const next = grouped(L + 1, g);
const len = Math.min(curr.length, next.length);
for (let i = 0; i < len; i++) addEdge(curr[i].id, next[i].id);
for (let i = 0; i < len - 1; i++) {
if (i % 2 === 0) addEdge(curr[i].id, next[i + 1].id);
else addEdge(curr[i + 1].id, next[i].id);
}
});
}
for (let L = 0; L < Z_LAYERS - 2; L++) {
['left', 'right'].forEach(g => {
const curr = nodes.filter(n => n.layer === L && n.group === g);
const skip = nodes.filter(n => n.layer === L + 2 && n.group === g);
const len = Math.min(curr.length, skip.length);
for (let i = 0; i < len; i += 2) addEdge(curr[i].id, skip[i].id);
});
}
for (let L = 0; L < Z_LAYERS; L++) {
const left = nodes.filter(n => n.layer === L && n.group === 'left');
const diag = nodes.filter(n => n.layer === L && n.group === 'diagonal');
const right = nodes.filter(n => n.layer === L && n.group === 'right');
left.forEach(ln => { diag.forEach(dn => { if (ln.pos.distanceTo(dn.pos) < 3.0) addEdge(ln.id, dn.id); }); });
right.forEach(rn => { diag.forEach(dn => { if (rn.pos.distanceTo(dn.pos) < 3.0) addEdge(rn.id, dn.id); }); });
}
const structural = nodes.filter(n => n.group !== 'fill');
const fill = nodes.filter(n => n.group === 'fill');
fill.forEach(fn => {
const sorted = structural.map(sn => ({ sn, d: sn.pos.distanceTo(fn.pos) })).sort((a, b) => a.d - b.d);
const count = 2 + Math.floor(Math.random() * 3);
for (let i = 0; i < Math.min(count, sorted.length); i++) {
if (sorted[i].d < 4.0) addEdge(fn.id, sorted[i].sn.id);
}
fill.forEach(fn2 => {
if (fn.id !== fn2.id && fn.pos.distanceTo(fn2.pos) < 2.5) addEdge(fn.id, fn2.id);
});
});
return edges;
}
const { nodes, structuralCount } = makeNodes();
const edges = makeEdges(nodes);
const adjacency = new Map();
nodes.forEach(n => adjacency.set(n.id, []));
edges.forEach(([a, b]) => { adjacency.get(a).push(b); adjacency.get(b).push(a); });
nodes.forEach(n => { n.connections = adjacency.get(n.id).length; });
// ─── Physics state ───────────────────────────────────────────────
const physics = nodes.map(n => ({
restPos: n.pos.clone(),
livePos: n.pos.clone(),
vel: new THREE.Vector3(0, 0, 0),
}));
// ─── Edges (cylinder tubes) ─────────────────────────────────────
const edgeGroup = new THREE.Group();
const edgeMeshes = [];
const _up = new THREE.Vector3(0, 1, 0);
const unitCylGeo = new THREE.CylinderGeometry(1, 1, 1, 5, 1);
edges.forEach(([aId, bId]) => {
const mat = new THREE.MeshBasicMaterial({ color: params.edgeColor.clone(), transparent: true, opacity: 0 });
const mesh = new THREE.Mesh(unitCylGeo, mat);
edgeGroup.add(mesh);
edgeMeshes.push({ mesh, mat, aId, bId });
});
scene.add(edgeGroup);
const _dir = new THREE.Vector3(), _mid = new THREE.Vector3();
const _axis = new THREE.Vector3(), _quat = new THREE.Quaternion();
function updateEdgeMesh(em) {
const a = physics[em.aId].livePos, b = physics[em.bId].livePos;
_dir.subVectors(b, a);
const len = _dir.length();
_mid.addVectors(a, b).multiplyScalar(0.5);
em.mesh.position.copy(_mid);
em.mesh.scale.set(params.edgeWidth, len, params.edgeWidth);
if (len > 0.001) {
_axis.copy(_dir).normalize();
_quat.setFromUnitVectors(_up, _axis);
em.mesh.setRotationFromQuaternion(_quat);
}
}
edgeMeshes.forEach(em => updateEdgeMesh(em));
// ─── Nodes (simple spheres) ─────────────────────────────────────
const nodeGroup = new THREE.Group();
const nodeMeshes = [];
const nodeGeom = new THREE.SphereGeometry(1, 12, 8);
nodes.forEach(n => {
const mat = new THREE.MeshBasicMaterial({ color: params.nodeColor.clone(), transparent: true, opacity: 0 });
const mesh = new THREE.Mesh(nodeGeom, mat);
mesh.position.copy(n.pos);
mesh.scale.setScalar(0.001); // start invisible for intro
nodeGroup.add(mesh);
nodeMeshes.push({ mesh, mat, node: n, baseScale: 1, stimulation: 0 });
});
scene.add(nodeGroup);
// ─── Pulse System ───────────────────────────────────────────────
const pulses = [];
const packetGeom = new THREE.SphereGeometry(1, 6, 4);
const REFRACTORY = 1.2;
const nodeLastFired = new Map();
function fireNode(nodeId, elapsed, excludeEdgeFrom) {
const lastFired = nodeLastFired.get(nodeId) || -999;
if (elapsed - lastFired < REFRACTORY) return;
nodeLastFired.set(nodeId, elapsed);
const nm = nodeMeshes[nodeId];
nm.stimulation = 1.0;
const neighbors = adjacency.get(nodeId);
neighbors.forEach(neighborId => {
if (neighborId === excludeEdgeFrom) return;
const mat = new THREE.MeshBasicMaterial({ color: params.pulseColor.clone(), transparent: true, opacity: 1 });
const mesh = new THREE.Mesh(packetGeom, mat);
mesh.scale.setScalar(params.nodeSize * 0.5);
scene.add(mesh);
pulses.push({ mesh, mat, fromId: nodeId, toId: neighborId, t: 0, speed: params.pulseSpeed * (0.6 + Math.random() * 0.8) });
});
}
let pulseAccumulator = 0;
// ─── Collision Particles ────────────────────────────────────────
const collisionParticles = [];
const cpGeom = new THREE.SphereGeometry(1, 6, 4);
function spawnCollisionParticle() {
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
const R = 14 + Math.random() * 6;
const pos = new THREE.Vector3(R * Math.sin(phi) * Math.cos(theta), R * Math.sin(phi) * Math.sin(theta), R * Math.cos(phi));
const target = new THREE.Vector3((Math.random() - 0.5) * WIDTH, (Math.random() - 0.5) * HEIGHT, (Math.random() - 0.5) * DEPTH);
const dir = new THREE.Vector3().subVectors(target, pos).normalize();
const speed = params.particleSpeed * (0.5 + Math.random());
const mat = new THREE.MeshBasicMaterial({ color: params.particleColor.clone(), transparent: true, opacity: 0.8 });
const mesh = new THREE.Mesh(cpGeom, mat);
mesh.position.copy(pos);
mesh.scale.setScalar(params.particleSize);
scene.add(mesh);
return { mesh, mat, pos: pos.clone(), vel: dir.multiplyScalar(speed), life: 0, maxLife: 12 + Math.random() * 8 };
}
// ─── Background particles ───────────────────────────────────────
const bgParticleCount = 400;
const pGeo = new THREE.BufferGeometry();
const pPos = new Float32Array(bgParticleCount * 3);
for (let i = 0; i < bgParticleCount; i++) {
pPos[i * 3] = (Math.random() - 0.5) * 35;
pPos[i * 3 + 1] = (Math.random() - 0.5) * 25;
pPos[i * 3 + 2] = (Math.random() - 0.5) * 25;
}
pGeo.setAttribute('position', new THREE.BufferAttribute(pPos, 3));
scene.add(new THREE.Points(pGeo, new THREE.PointsMaterial({ color: 0x223344, size: 0.04, transparent: true, opacity: 0.45 })));
const grid = new THREE.GridHelper(50, 50, 0x0f1a28, 0x0a1118);
grid.position.y = -HEIGHT / 2 - 1.8;
grid.material.opacity = 0;
grid.material.transparent = true;
scene.add(grid);
// ─── Mouse tracking / dragging ──────────────────────────────────
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2(-999, -999);
let hoveredNode = null;
const mouseWorld = new THREE.Vector3();
const mousePlane = new THREE.Plane();
const _planeNormal = new THREE.Vector3();
let mouseActive = false;
// Drag state
let dragging = false;
let dragNodeIdx = -1;
const dragPlane = new THREE.Plane();
const dragOffset = new THREE.Vector3();
const _dragIntersect = new THREE.Vector3();
window.addEventListener('pointermove', e => {
if (e.target.closest('#controls') || e.target.closest('#toggle-btn') || e.target.closest('#scrubber-bar')) {
mouseActive = false;
return;
}
mouse.x = (e.clientX / innerWidth) * 2 - 1;
mouse.y = -(e.clientY / innerHeight) * 2 + 1;
mouseActive = true;
if (dragging && dragNodeIdx >= 0) {
raycaster.setFromCamera(mouse, camera);
if (raycaster.ray.intersectPlane(dragPlane, _dragIntersect)) {
const newPos = _dragIntersect.sub(dragOffset);
physics[dragNodeIdx].restPos.copy(newPos);
physics[dragNodeIdx].livePos.copy(newPos);
physics[dragNodeIdx].vel.set(0, 0, 0);
}
}
});
window.addEventListener('pointerleave', () => { mouseActive = false; mouse.set(-999, -999); });
window.addEventListener('pointerdown', e => {
if (e.target.closest('#controls') || e.target.closest('#toggle-btn') || e.target.closest('#scrubber-bar')) return;
if (!params.dragMode) return;
raycaster.setFromCamera(mouse, camera);
const hitTargets = nodeMeshes.map(nm => nm.mesh);
const hits = raycaster.intersectObjects(hitTargets);
if (hits.length > 0) {
const idx = hitTargets.indexOf(hits[0].object);
dragNodeIdx = idx;
dragging = true;
orbitControls.enabled = false;
// Build a drag plane facing the camera through the node
_planeNormal.copy(camera.position).sub(physics[idx].livePos).normalize();
dragPlane.setFromNormalAndCoplanarPoint(_planeNormal, physics[idx].livePos);
raycaster.ray.intersectPlane(dragPlane, _dragIntersect);
dragOffset.subVectors(_dragIntersect, physics[idx].livePos);
}
});
window.addEventListener('pointerup', () => {
if (dragging) {
dragging = false;
dragNodeIdx = -1;
orbitControls.enabled = true;
}
});
window.addEventListener('click', e => {
if (e.target.closest('#controls') || e.target.closest('#toggle-btn') || e.target.closest('#scrubber-bar')) return;
if (params.dragMode) return; // don't fire pulses while in drag mode
if (hoveredNode) fireNode(hoveredNode.node.id, clock.getElapsedTime(), -1);
});
// ─── Collision helpers ──────────────────────────────────────────
const _ab = new THREE.Vector3(), _ap = new THREE.Vector3();
function closestPointOnSegment(a, b, p, outPoint) {
_ab.subVectors(b, a); _ap.subVectors(p, a);
let t = _ap.dot(_ab) / _ab.dot(_ab);
t = Math.max(0, Math.min(1, t));
outPoint.copy(a).addScaledVector(_ab, t);
return t;
}
const _closestPt = new THREE.Vector3(), _normal = new THREE.Vector3();
// ══════════════════════════════════════════════════════════════════
// ─── GSAP ANIMATIONS ────────────────────────────────────────────
// ══════════════════════════════════════════════════════════════════
let introPlayed = false;
let activeScrubTl = null;
function showScrubber(tl) {
const bar = document.getElementById('scrubber-bar');
const scrubber = document.getElementById('scrubber');
const timeLabel = document.getElementById('scrub-time');
const playBtn = document.getElementById('scrub-play');
activeScrubTl = tl;
bar.style.display = 'flex';
let isScrubbing = false;
scrubber.value = 0;
const updateLabel = () => {
timeLabel.textContent = tl.time().toFixed(1) + 's';
if (!isScrubbing) scrubber.value = tl.progress();
};
tl.eventCallback('onUpdate', updateLabel);
tl.eventCallback('onComplete', () => {
setTimeout(() => { bar.style.display = 'none'; activeScrubTl = null; }, 1500);
});
scrubber.oninput = () => {
isScrubbing = true;
tl.pause();
tl.progress(parseFloat(scrubber.value));
playBtn.innerHTML = '&#9654;';
};
scrubber.onchange = () => { isScrubbing = false; };
let playing = true;
playBtn.innerHTML = '&#9646;&#9646;';
playBtn.onclick = () => {
if (playing) { tl.pause(); playBtn.innerHTML = '&#9654;'; }
else { tl.play(); playBtn.innerHTML = '&#9646;&#9646;'; }
playing = !playing;
};
}
// ── 1. Intro Animation ──────────────────────────────────────────
function playIntro() {
// Scatter nodes to random far positions
const scatterPositions = nodes.map(() => ({
x: (Math.random() - 0.5) * 60,
y: (Math.random() - 0.5) * 40,
z: (Math.random() - 0.5) * 40,
}));
// Set initial scattered state
physics.forEach((p, i) => {
p.livePos.set(scatterPositions[i].x, scatterPositions[i].y, scatterPositions[i].z);
nodeMeshes[i].mesh.position.copy(p.livePos);
nodeMeshes[i].mat.opacity = 0;
nodeMeshes[i].mesh.scale.setScalar(0.001);
});
const tl = gsap.timeline();
// Camera zoom in
tl.to(camera.position, { x: 4, y: 3, z: 22, duration: 3, ease: 'power2.inOut' }, 0);
// Stagger nodes flying in with elastic bounce
physics.forEach((p, i) => {
const delay = 0.3 + (i / nodes.length) * 1.5 + Math.random() * 0.3;
const target = p.restPos;
tl.to(p.livePos, {
x: target.x, y: target.y, z: target.z,
duration: 1.8,
ease: 'elastic.out(1, 0.5)',
}, delay);
// Fade in + scale up
tl.to(nodeMeshes[i].mat, { opacity: 1, duration: 0.4 }, delay);
tl.to(nodeMeshes[i].mesh.scale, {
x: params.nodeSize, y: params.nodeSize, z: params.nodeSize,
duration: 1.2,
ease: 'elastic.out(1, 0.4)',
}, delay);
});
// Edges fade in after nodes settle
tl.to({}, { duration: 0.1, onComplete: () => {
edgeMeshes.forEach((em, i) => {
gsap.to(em.mat, { opacity: params.edgeOpacity, duration: 0.6, delay: i * 0.003 });
});
}}, 1.5);
// Grid fade in
tl.to(grid.material, { opacity: 1, duration: 1.5 }, 2.5);
// UI fade in
tl.to('#info', { opacity: 0.55, duration: 1 }, 3);
tl.to('#toggle-btn', { opacity: 1, duration: 0.5 }, 3.2);
tl.to('#controls', { opacity: 1, duration: 0.8 }, 3.5);
// Fire some showcase pulses
tl.call(() => {
for (let i = 0; i < 5; i++) {
setTimeout(() => fireNode(Math.floor(Math.random() * nodes.length), clock.getElapsedTime(), -1), i * 200);
}
}, [], 3.5);
introPlayed = true;
showScrubber(tl);
}
// ── 2. Camera Fly-through ───────────────────────────────────────
function playFlythrough() {
const tl = gsap.timeline();
const savedAutoRotate = params.autoRotate;
orbitControls.autoRotate = false;
// Waypoints: position + lookAt
const waypoints = [
{ pos: { x: 15, y: 8, z: 15 }, dur: 2.5 },
{ pos: { x: -3, y: 0, z: 6 }, dur: 3 }, // fly through the N
{ pos: { x: 0, y: -2, z: -8 }, dur: 2.5 }, // behind
{ pos: { x: -12, y: 6, z: 10 }, dur: 3 }, // side orbit
{ pos: { x: 4, y: 3, z: 22 }, dur: 2.5 }, // back to start
];
let t = 0;
waypoints.forEach(wp => {
tl.to(camera.position, {
...wp.pos,
duration: wp.dur,
ease: 'power1.inOut',
onUpdate: () => camera.lookAt(0, 0, 0),
}, t);
t += wp.dur - 0.5; // overlap
});
tl.call(() => { orbitControls.autoRotate = savedAutoRotate; });
// Fire some pulses during flythrough
tl.call(() => fireNode(Math.floor(Math.random() * nodes.length), clock.getElapsedTime(), -1), [], 1);
tl.call(() => fireNode(Math.floor(Math.random() * nodes.length), clock.getElapsedTime(), -1), [], 3);
tl.call(() => fireNode(Math.floor(Math.random() * nodes.length), clock.getElapsedTime(), -1), [], 5);
showScrubber(tl);
}
// ── 3. Explode / Implode ────────────────────────────────────────
function playExplode() {
const tl = gsap.timeline();
// Explode outward
physics.forEach((p, i) => {
const dir = p.restPos.clone().normalize().multiplyScalar(8 + Math.random() * 6);
const exploded = p.restPos.clone().add(dir);
tl.to(p.restPos, { x: exploded.x, y: exploded.y, z: exploded.z, duration: 1.2, ease: 'power3.out' }, i * 0.005);
});
// Hold
tl.to({}, { duration: 1.5 });
// Implode back
const origPositions = nodes.map(n => n.pos.clone());
physics.forEach((p, i) => {
const orig = origPositions[i];
tl.to(p.restPos, { x: orig.x, y: orig.y, z: orig.z, duration: 1.5, ease: 'elastic.out(1, 0.6)' }, 2.7 + i * 0.005);
});
showScrubber(tl);
}
// ── 4. Shape Morphing ───────────────────────────────────────────
function morphTo(shapeFn) {
const targetPositions = shapeFn();
const tl = gsap.timeline();
// Only morph structural nodes (fill nodes stay put with slight drift)
for (let i = 0; i < structuralCount && i < targetPositions.length; i++) {
const tp = targetPositions[i];
tl.to(physics[i].restPos, {
x: tp.x, y: tp.y, z: tp.z,
duration: 2,
ease: 'elastic.out(1, 0.6)',
}, i * 0.008);
}
// Fill nodes: gently attract toward nearest new structural position
for (let i = structuralCount; i < nodes.length; i++) {
const fp = physics[i].restPos;
let closest = targetPositions[0], minD = 999;
for (let j = 0; j < targetPositions.length; j++) {
const d = Math.sqrt((fp.x - targetPositions[j].x) ** 2 + (fp.y - targetPositions[j].y) ** 2 + (fp.z - targetPositions[j].z) ** 2);
if (d < minD) { minD = d; closest = targetPositions[j]; }
}
const mid = { x: (fp.x + closest.x) / 2 + (Math.random() - 0.5) * 2, y: (fp.y + closest.y) / 2, z: (fp.z + closest.z) / 2 };
tl.to(physics[i].restPos, { x: mid.x, y: mid.y, z: mid.z, duration: 2, ease: 'power2.inOut' }, 0.5);
}
// Flash all nodes during morph
tl.call(() => {
nodeMeshes.forEach(nm => nm.stimulation = 0.6);
}, [], 0.5);
showScrubber(tl);
}
// ── 5. Wave Animation ───────────────────────────────────────────
function playWave() {
const tl = gsap.timeline();
physics.forEach((p, i) => {
const origY = p.restPos.y;
const delay = (p.restPos.x + WIDTH / 2) / WIDTH * 1.5 + (p.restPos.z + DEPTH / 2) / DEPTH * 0.3;
tl.to(p.restPos, { y: origY + 2, duration: 0.4, ease: 'power2.out', yoyo: true, repeat: 1 }, delay);
});
// Cascade of pulses with the wave
tl.call(() => {
for (let i = 0; i < 8; i++) {
setTimeout(() => fireNode(Math.floor(Math.random() * nodes.length), clock.getElapsedTime(), -1), i * 150);
}
}, [], 0.3);
showScrubber(tl);
}
// ── 6. Heartbeat ────────────────────────────────────────────────
function playHeartbeat() {
const tl = gsap.timeline({ repeat: 3 });
const center = new THREE.Vector3(0, 0, 0);
// First thump
physics.forEach((p, i) => {
const dir = p.restPos.clone().sub(center).normalize().multiplyScalar(0.6);
const expanded = { x: p.restPos.x + dir.x, y: p.restPos.y + dir.y, z: p.restPos.z + dir.z };
tl.to(p.restPos, { ...expanded, duration: 0.12, ease: 'power3.out' }, 0);
tl.to(p.restPos, { x: nodes[i].pos.x, y: nodes[i].pos.y, z: nodes[i].pos.z, duration: 0.3, ease: 'power2.in' }, 0.12);
});
// Second (smaller) thump
physics.forEach((p, i) => {
const dir = p.restPos.clone().sub(center).normalize().multiplyScalar(0.35);
const expanded = { x: nodes[i].pos.x + dir.x, y: nodes[i].pos.y + dir.y, z: nodes[i].pos.z + dir.z };
tl.to(p.restPos, { ...expanded, duration: 0.1, ease: 'power3.out' }, 0.55);
tl.to(p.restPos, { x: nodes[i].pos.x, y: nodes[i].pos.y, z: nodes[i].pos.z, duration: 0.25, ease: 'power2.in' }, 0.65);
});
tl.call(() => {
nodeMeshes.forEach(nm => nm.stimulation = 0.8);
}, [], 0.05);
tl.to({}, { duration: 0.3 }); // pause between beats
showScrubber(tl);
}
// ── 7. Spiral ───────────────────────────────────────────────────
function playSpiral() {
const tl = gsap.timeline();
const origPositions = nodes.map(n => n.pos.clone());
// Spiral outward
physics.forEach((p, i) => {
const angle = (i / nodes.length) * Math.PI * 4;
const radius = 3 + (i / nodes.length) * 5;
const spiralY = -HEIGHT / 2 + (i / nodes.length) * HEIGHT;
const spiralPos = {
x: Math.cos(angle) * radius,
y: spiralY,
z: Math.sin(angle) * radius,
};
tl.to(p.restPos, { ...spiralPos, duration: 2, ease: 'power2.inOut' }, i * 0.01);
});
tl.to({}, { duration: 1 }); // hold
// Spiral back
physics.forEach((p, i) => {
const orig = origPositions[i];
tl.to(p.restPos, { x: orig.x, y: orig.y, z: orig.z, duration: 2, ease: 'elastic.out(1, 0.5)' }, 3.2 + i * 0.01);
});
// Camera follows
tl.to(camera.position, { x: 8, y: 10, z: 18, duration: 2, ease: 'power1.inOut' }, 0);
tl.to(camera.position, { x: 4, y: 3, z: 22, duration: 2, ease: 'power1.inOut' }, 3.2);
showScrubber(tl);
}
// ── Wire up buttons ─────────────────────────────────────────────
document.getElementById('btn-replay-intro').onclick = playIntro;
document.getElementById('btn-flythrough').onclick = playFlythrough;
document.getElementById('btn-explode').onclick = playExplode;
document.getElementById('btn-morph-E').onclick = () => morphTo(shapeE);
document.getElementById('btn-morph-T').onclick = () => morphTo(shapeT);
document.getElementById('btn-morph-N').onclick = () => morphTo(shapeN);
document.getElementById('btn-wave').onclick = playWave;
document.getElementById('btn-heartbeat').onclick = playHeartbeat;
document.getElementById('btn-spiral').onclick = playSpiral;
// ── Play intro on load ──────────────────────────────────────────
playIntro();
// ─── Animation loop ─────────────────────────────────────────────
const clock = new THREE.Clock();
const _force = new THREE.Vector3(), _toNode = new THREE.Vector3();
const _displacement = new THREE.Vector3(), _jiggleVec = new THREE.Vector3();
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
const elapsed = clock.getElapsedTime();
orbitControls.autoRotate = params.autoRotate;
orbitControls.autoRotateSpeed = params.rotSpeed;
bloom.strength = params.bloomStrength;
// Update CRT shader
crtPass.enabled = params.crtEnabled;
if (params.crtEnabled) {
const u = crtPass.uniforms;
u.uTime.value = clock.getElapsedTime();
u.uScanlineIntensity.value = params.crtScanlines;
u.uScanlineCount.value = params.crtScanlineCount;
u.uChromaShift.value = params.crtChroma;
u.uCurvature.value = params.crtCurvature;
u.uVignette.value = params.crtVignette;
u.uFlicker.value = params.crtFlicker;
u.uNoise.value = params.crtNoise;
u.uGlow.value = params.crtGlow;
u.uBrightness.value = params.crtBrightness;
u.uContrast.value = params.crtContrast;
u.uSaturation.value = params.crtSaturation;
}
orbitControls.update();
// Mouse -> world
if (mouseActive && !dragging) {
raycaster.setFromCamera(mouse, camera);
_planeNormal.copy(camera.position).normalize();
mousePlane.setFromNormalAndCoplanarPoint(_planeNormal, scene.position);
raycaster.ray.intersectPlane(mousePlane, mouseWorld);
}
// Physics
for (let i = 0; i < physics.length; i++) {
if (dragging && i === dragNodeIdx) continue; // skip dragged node
const p = physics[i];
_force.set(0, 0, 0);
_displacement.subVectors(p.restPos, p.livePos);
_force.addScaledVector(_displacement, params.springK);
_force.addScaledVector(p.vel, -params.damping);
if (mouseActive && !dragging && mouseWorld.x !== undefined) {
_toNode.subVectors(p.livePos, mouseWorld);
const dist = _toNode.length();
if (dist < params.repulsionRadius && dist > 0.01) {
const strength = params.repulsion / (dist * dist + 0.3);
_toNode.normalize();
_force.addScaledVector(_toNode, strength);
if (params.jiggle > 0) {
_jiggleVec.set((Math.random() - 0.5), (Math.random() - 0.5), (Math.random() - 0.5)).normalize();
_jiggleVec.addScaledVector(_toNode, -_jiggleVec.dot(_toNode));
_force.addScaledVector(_jiggleVec, params.jiggle * (1 - dist / params.repulsionRadius));
}
}
}
p.vel.addScaledVector(_force, dt);
p.livePos.addScaledVector(p.vel, dt);
}
// Update node positions
for (let i = 0; i < nodeMeshes.length; i++) {
nodeMeshes[i].mesh.position.copy(physics[i].livePos);
}
// Update edges
edgeMeshes.forEach(em => {
em.mesh.scale.x = params.edgeWidth;
em.mesh.scale.z = params.edgeWidth;
em.mat.opacity = params.edgeOpacity;
em.mat.color.copy(params.edgeColor);
updateEdgeMesh(em);
});
// Hover highlight (only when not dragging)
if (!dragging) {
raycaster.setFromCamera(mouse, camera);
const hitTargets = nodeMeshes.map(nm => nm.mesh);
const hits = raycaster.intersectObjects(hitTargets);
if (hoveredNode) { hoveredNode.mat.color.copy(params.nodeColor); document.body.style.cursor = 'default'; }
if (hits.length > 0) {
const idx = hitTargets.indexOf(hits[0].object);
hoveredNode = nodeMeshes[idx];
hoveredNode.mat.color.set(0xffffff);
document.body.style.cursor = params.dragMode ? 'grab' : 'pointer';
edgeMeshes.forEach(em => {
if (em.aId === hoveredNode.node.id || em.bId === hoveredNode.node.id) {
em.mat.opacity = Math.min(params.edgeOpacity + 0.45, 1);
em.mat.color.copy(params.nodeColor);
}
});
} else { hoveredNode = null; }
} else {
document.body.style.cursor = 'grabbing';
}
// Node visuals
nodeMeshes.forEach((nm, i) => {
const breathe = 1 + Math.sin(elapsed * 1.5 + i * 0.6) * 0.06;
if (nm.stimulation > 0) {
nm.stimulation = Math.max(0, nm.stimulation - dt * 1.8);
const s = breathe * (1 + nm.stimulation * 0.8);
nm.mesh.scale.setScalar(params.nodeSize * s * nm.baseScale);
nm.mat.color.lerpColors(params.nodeColor, params.pulseColor, nm.stimulation);
} else {
nm.mesh.scale.setScalar(params.nodeSize * breathe * nm.baseScale);
if (nm !== hoveredNode) nm.mat.color.copy(params.nodeColor);
}
});
// Pulses
pulseAccumulator += dt * params.pulseRate;
while (pulseAccumulator >= 1) { pulseAccumulator -= 1; fireNode(Math.floor(Math.random() * nodes.length), elapsed, -1); }
for (let i = pulses.length - 1; i >= 0; i--) {
const p = pulses[i];
const fromPos = physics[p.fromId].livePos, toPos = physics[p.toId].livePos;
const edgeLen = fromPos.distanceTo(toPos);
const travelDuration = edgeLen / (p.speed * 3);
p.t += dt / Math.max(travelDuration, 0.01);
if (p.t >= 1) {
scene.remove(p.mesh); p.mesh.geometry.dispose(); p.mat.dispose(); pulses.splice(i, 1);
if (Math.random() < params.cascadeChance) fireNode(p.toId, elapsed, p.fromId);
else nodeMeshes[p.toId].stimulation = Math.max(nodeMeshes[p.toId].stimulation, 0.3);
} else {
p.mesh.position.lerpVectors(fromPos, toPos, p.t);
p.mat.opacity = 1 - p.t * 0.7;
p.mesh.scale.setScalar(params.nodeSize * 0.5 * (1 + Math.sin(p.t * Math.PI) * 0.3));
p.mat.color.lerpColors(params.pulseColor, params.edgeColor, p.t * 0.6);
}
}
// Collision particles
const targetCount = Math.round(params.particleCount);
while (collisionParticles.length < targetCount) collisionParticles.push(spawnCollisionParticle());
while (collisionParticles.length > targetCount) { const r = collisionParticles.pop(); scene.remove(r.mesh); r.mesh.geometry.dispose(); r.mat.dispose(); }
const nodeCollisionR = params.nodeSize * 1.5 + params.particleSize;
const edgeCollisionR = params.edgeWidth * 2 + params.particleSize;
for (let i = collisionParticles.length - 1; i >= 0; i--) {
const cp = collisionParticles[i];
const currentSpeed = cp.vel.length();
if (currentSpeed > 0.001) cp.vel.normalize().multiplyScalar(currentSpeed + (params.particleSpeed - currentSpeed) * dt * 2);
cp.pos.addScaledVector(cp.vel, dt);
cp.mesh.position.copy(cp.pos);
cp.mesh.scale.setScalar(params.particleSize);
cp.mat.color.copy(params.particleColor);
cp.life += dt;
const lifeFrac = cp.life / cp.maxLife;
cp.mat.opacity = Math.min(Math.min(lifeFrac * 5, 1), Math.max(1 - (lifeFrac - 0.8) * 5, 0)) * 0.8;
if (cp.life > cp.maxLife || cp.pos.length() > 30) {
scene.remove(cp.mesh); cp.mesh.geometry.dispose(); cp.mat.dispose();
collisionParticles[i] = spawnCollisionParticle();
continue;
}
let collided = false;
for (let j = 0; j < physics.length; j++) {
_toNode.subVectors(cp.pos, physics[j].livePos);
const dist = _toNode.length();
if (dist < nodeCollisionR) {
_normal.copy(_toNode).normalize();
const dot = cp.vel.dot(_normal);
if (dot < 0) {
cp.vel.addScaledVector(_normal, -2 * dot);
cp.pos.copy(physics[j].livePos).addScaledVector(_normal, nodeCollisionR * 1.05);
nodeMeshes[j].stimulation = Math.max(nodeMeshes[j].stimulation, 0.7);
physics[j].vel.addScaledVector(_normal, -0.5);
cp.mat.opacity = 1;
fireNode(j, elapsed, -1);
collided = true;
break;
}
}
}
if (!collided) {
for (let j = 0; j < edges.length; j++) {
const aPos = physics[edges[j][0]].livePos, bPos = physics[edges[j][1]].livePos;
closestPointOnSegment(aPos, bPos, cp.pos, _closestPt);
_normal.subVectors(cp.pos, _closestPt);
const dist = _normal.length();
if (dist < edgeCollisionR && dist > 0.001) {
_normal.normalize();
const dot = cp.vel.dot(_normal);
if (dot < 0) {
cp.vel.addScaledVector(_normal, -2 * dot);
cp.pos.copy(_closestPt).addScaledVector(_normal, edgeCollisionR * 1.05);
cp.mat.opacity = 1;
nodeMeshes[edges[j][0]].stimulation = Math.max(nodeMeshes[edges[j][0]].stimulation, 0.3);
nodeMeshes[edges[j][1]].stimulation = Math.max(nodeMeshes[edges[j][1]].stimulation, 0.3);
physics[edges[j][0]].vel.addScaledVector(_normal, -0.2);
physics[edges[j][1]].vel.addScaledVector(_normal, -0.2);
break;
}
}
}
}
}
composer.render();
}
animate();
window.addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
composer.setSize(innerWidth, innerHeight);
crtPass.uniforms.uResolution.value.set(innerWidth, innerHeight);
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment