Created
February 17, 2026 20:22
-
-
Save withakay/1bdd9e7ee1c1e2953fd727e152641815 to your computer and use it in GitHub Desktop.
Network N — Interactive 3D Wireframe | Three.js + GSAP + CRT GLSL Shader
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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 · Scroll to zoom · <span>Hover</span> to push nodes · <span>Click</span> to pulse · <span>Shift+Drag</span> to move nodes | |
| </div> | |
| <button id="toggle-btn" title="Toggle controls">⚙</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">▶</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 = '▶'; | |
| }; | |
| scrubber.onchange = () => { isScrubbing = false; }; | |
| let playing = true; | |
| playBtn.innerHTML = '▮▮'; | |
| playBtn.onclick = () => { | |
| if (playing) { tl.pause(); playBtn.innerHTML = '▶'; } | |
| else { tl.play(); playBtn.innerHTML = '▮▮'; } | |
| 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