Created
May 1, 2026 08:59
-
-
Save TheAnonymous/92862e8b86a53ac42ce6990ccfb295a1 to your computer and use it in GitHub Desktop.
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>Wireframe Sculpture</title> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { overflow: hidden; background: #08080f; font-family: 'Segoe UI', system-ui, sans-serif; } | |
| canvas { display: block; } | |
| #vignette { | |
| position: fixed; inset: 0; | |
| z-index: 2; | |
| pointer-events: none; | |
| background: radial-gradient(ellipse at center, transparent 45%, rgba(0,0,0,0.5) 100%); | |
| } | |
| #info { | |
| position: fixed; | |
| bottom: 20px; left: 50%; | |
| transform: translateX(-50%); | |
| color: rgba(180,160,130,0.15); | |
| font-size: 0.65rem; | |
| letter-spacing: 0.1em; | |
| z-index: 5; | |
| pointer-events: none; | |
| text-align: center; | |
| } | |
| #controls { | |
| position: fixed; | |
| left: 20px; | |
| bottom: 56px; | |
| z-index: 5; | |
| background: rgba(8,8,15,0.7); | |
| backdrop-filter: blur(8px); | |
| -webkit-backdrop-filter: blur(8px); | |
| border: 1px solid rgba(180,160,130,0.12); | |
| border-radius: 12px; | |
| padding: 14px 18px 8px; | |
| min-width: 170px; | |
| user-select: none; | |
| } | |
| #controls .ctrl-group { margin-bottom: 6px; } | |
| #controls .ctrl-group:last-child { margin-bottom: 2px; } | |
| #controls .ctrl-label { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| color: rgba(180,160,130,0.45); | |
| font-size: 0.6rem; | |
| letter-spacing: 0.06em; | |
| margin-bottom: 1px; | |
| } | |
| #controls .ctrl-label .val { | |
| color: rgba(160,180,200,0.3); | |
| font-variant-numeric: tabular-nums; | |
| } | |
| #controls input[type="range"] { | |
| -webkit-appearance: none; | |
| appearance: none; | |
| width: 100%; | |
| height: 2px; | |
| background: rgba(180,160,130,0.12); | |
| border-radius: 1px; | |
| outline: none; | |
| cursor: pointer; | |
| } | |
| #controls input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 10px; height: 10px; | |
| border-radius: 50%; | |
| background: rgba(180,160,130,0.5); | |
| border: 1px solid rgba(180,160,130,0.15); | |
| cursor: pointer; | |
| transition: background 0.2s; | |
| } | |
| #controls input[type="range"]::-webkit-slider-thumb:hover { | |
| background: rgba(200,180,150,0.7); | |
| } | |
| #controls input[type="range"]::-moz-range-thumb { | |
| width: 10px; height: 10px; | |
| border-radius: 50%; | |
| background: rgba(180,160,130,0.5); | |
| border: 1px solid rgba(180,160,130,0.15); | |
| cursor: pointer; | |
| } | |
| #fullscreen-btn { | |
| position: fixed; | |
| top: 16px; right: 16px; | |
| z-index: 5; | |
| background: rgba(8,8,15,0.6); | |
| backdrop-filter: blur(8px); | |
| -webkit-backdrop-filter: blur(8px); | |
| border: 1px solid rgba(180,160,130,0.12); | |
| border-radius: 8px; | |
| color: rgba(180,160,130,0.45); | |
| width: 32px; height: 32px; | |
| display: flex; align-items: center; justify-content: center; | |
| cursor: pointer; | |
| transition: background 0.2s, border-color 0.2s; | |
| } | |
| #fullscreen-btn:hover { | |
| background: rgba(8,8,15,0.8); | |
| border-color: rgba(180,160,130,0.25); | |
| } | |
| #fullscreen-btn svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.5; } | |
| #export-btn { | |
| position: fixed; | |
| top: 16px; right: 56px; | |
| z-index: 5; | |
| background: rgba(8,8,15,0.6); | |
| backdrop-filter: blur(8px); | |
| -webkit-backdrop-filter: blur(8px); | |
| border: 1px solid rgba(180,160,130,0.12); | |
| border-radius: 8px; | |
| color: rgba(180,160,130,0.45); | |
| width: 32px; height: 32px; | |
| display: flex; align-items: center; justify-content: center; | |
| cursor: pointer; | |
| transition: background 0.2s, border-color 0.2s; | |
| } | |
| #export-btn:hover { | |
| background: rgba(8,8,15,0.8); | |
| border-color: rgba(180,160,130,0.25); | |
| } | |
| #export-btn svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.5; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="vignette"></div> | |
| <div id="info">drag to orbit • scroll to zoom • auto-rotate • <b>F</b> flythrough</div> | |
| <button id="export-btn" title="Export snapshot"> | |
| <svg viewBox="0 0 24 24"> | |
| <path d="M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2"/> | |
| </svg> | |
| </button> | |
| <button id="fullscreen-btn" title="Toggle fullscreen"> | |
| <svg viewBox="0 0 24 24"> | |
| <path d="M3 3h5v2H5v3H3V3zm13 0h5v5h-2V5h-3V3zM3 16h2v3h3v2H3v-5zm13 5v-2h3v-3h2v5h-5z"/> | |
| </svg> | |
| </button> | |
| <div id="controls"> | |
| <div class="ctrl-group"> | |
| <div class="ctrl-label"><span>Amplitude</span><span class="val" id="val-waveHeight">1.0</span></div> | |
| <input type="range" id="waveHeight" min="0" max="2.0" step="0.05" value="1.0"> | |
| </div> | |
| <div class="ctrl-group"> | |
| <div class="ctrl-label"><span>Speed</span><span class="val" id="val-waveSpeed">1.0</span></div> | |
| <input type="range" id="waveSpeed" min="0" max="2.0" step="0.05" value="1.0"> | |
| </div> | |
| <div class="ctrl-group"> | |
| <div class="ctrl-label"><span>Ico Particles</span><span class="val" id="val-icoCount">500</span></div> | |
| <input type="range" id="icoCount" min="0" max="3000" step="50" value="500"> | |
| </div> | |
| <div class="ctrl-group"> | |
| <div class="ctrl-label"><span>Knot Particles</span><span class="val" id="val-knotCount">250</span></div> | |
| <input type="range" id="knotCount" min="0" max="2000" step="50" value="250"> | |
| </div> | |
| <div class="ctrl-group"> | |
| <div class="ctrl-label"><span>Particle Size</span><span class="val" id="val-particleSize">0.13</span></div> | |
| <input type="range" id="particleSize" min="0.02" max="0.5" step="0.01" value="0.13"> | |
| </div> | |
| <div class="ctrl-group"> | |
| <div class="ctrl-label"><span>Morph</span><span class="val" id="val-morphTarget">0.00</span></div> | |
| <input type="range" id="morphTarget" min="0" max="2" step="0.01" value="0"> | |
| </div> | |
| <div class="ctrl-group"> | |
| <div class="ctrl-label"><span>Flow Speed</span><span class="val" id="val-flowSpeed">1.0</span></div> | |
| <input type="range" id="flowSpeed" min="0" max="3.0" step="0.05" value="1.0"> | |
| </div> | |
| </div> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "three": "https://unpkg.com/three@0.162.0/build/three.module.js", | |
| "three/addons/": "https://unpkg.com/three@0.162.0/examples/jsm/" | |
| } | |
| } | |
| </script> | |
| <script type="module"> | |
| import * as THREE from 'three'; | |
| import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; | |
| // ─── Scene ──────────────────────────────────────────────────────────── | |
| const scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x08080f); | |
| scene.fog = new THREE.Fog(0x08080f, 30, 60); | |
| const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 120); | |
| camera.position.set(14, 8, 18); | |
| const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); | |
| renderer.setSize(innerWidth, innerHeight); | |
| renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); | |
| renderer.toneMapping = THREE.ACESFilmicToneMapping; | |
| renderer.toneMappingExposure = 1.2; | |
| document.body.prepend(renderer.domElement); | |
| // ─── Controls ───────────────────────────────────────────────────────── | |
| const controls = new OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; | |
| controls.dampingFactor = 0.06; | |
| controls.target.set(0, 0, 0); | |
| controls.minDistance = 6; | |
| controls.maxDistance = 45; | |
| controls.maxPolarAngle = Math.PI * 0.85; | |
| // ─── Lighting ───────────────────────────────────────────────────────── | |
| const ambient = new THREE.AmbientLight(0x1a1a2e, 0.6); | |
| scene.add(ambient); | |
| const hemi = new THREE.HemisphereLight(0xc8b890, 0x08080f, 0.4); | |
| scene.add(hemi); | |
| const key = new THREE.DirectionalLight(0xe0d0b8, 1.8); | |
| key.position.set(10, 18, 8); | |
| scene.add(key); | |
| const fill = new THREE.DirectionalLight(0x8898a8, 0.6); | |
| fill.position.set(-12, 4, -10); | |
| scene.add(fill); | |
| // ─── Wireframe Structure 1: Icosahedron ───────────────────────────── | |
| const ICOSA_RADIUS = 6.5; | |
| const ICOSA_DETAIL = 3; | |
| const icoGeo = new THREE.IcosahedronGeometry(ICOSA_RADIUS, ICOSA_DETAIL); | |
| const icoPosOrig = new Float32Array(icoGeo.attributes.position.array); | |
| const icoMat1 = new THREE.MeshBasicMaterial({ | |
| color: 0xc8b890, | |
| wireframe: true, | |
| transparent: true, | |
| opacity: 0.3, | |
| }); | |
| const icoMesh1 = new THREE.Mesh(icoGeo, icoMat1); | |
| scene.add(icoMesh1); | |
| const icoMat2 = new THREE.MeshBasicMaterial({ | |
| color: 0xe8e0d0, | |
| wireframe: true, | |
| transparent: true, | |
| opacity: 0.06, | |
| }); | |
| const icoMesh2 = new THREE.Mesh(icoGeo, icoMat2); | |
| scene.add(icoMesh2); | |
| // ─── Wireframe Structure 2: Torus Knot ────────────────────────────── | |
| const KNOT_RADIUS = 3.2; | |
| const KNOT_TUBE = 0.9; | |
| const KNOT_P = 2; | |
| const KNOT_Q = 3; | |
| const knotGeo = new THREE.TorusKnotGeometry(KNOT_RADIUS, KNOT_TUBE, 80, 12, KNOT_P, KNOT_Q); | |
| const knotPosOrig = new Float32Array(knotGeo.attributes.position.array); | |
| const knotMat1 = new THREE.MeshBasicMaterial({ | |
| color: 0x7a9aaa, | |
| wireframe: true, | |
| transparent: true, | |
| opacity: 0.25, | |
| }); | |
| const knotMesh1 = new THREE.Mesh(knotGeo, knotMat1); | |
| scene.add(knotMesh1); | |
| const knotMat2 = new THREE.MeshBasicMaterial({ | |
| color: 0xaabbc8, | |
| wireframe: true, | |
| transparent: true, | |
| opacity: 0.06, | |
| }); | |
| const knotMesh2 = new THREE.Mesh(knotGeo, knotMat2); | |
| scene.add(knotMesh2); | |
| // ─── Geometry Morphing ────────────────────────────────────────────── | |
| const icoVertCount = icoGeo.attributes.position.count; | |
| const icoPosMorphed = new Float32Array(icoPosOrig); | |
| const icoPosSphere = new Float32Array(icoPosOrig.length); | |
| const icoPosOcta = new Float32Array(icoPosOrig.length); | |
| function computeSpherePositions(orig, out, count, radius) { | |
| for (let i = 0; i < count; i++) { | |
| const i3 = i * 3; | |
| const x = orig[i3], y = orig[i3 + 1], z = orig[i3 + 2]; | |
| const len = Math.sqrt(x * x + y * y + z * z) || 1; | |
| out[i3] = (x / len) * radius; | |
| out[i3 + 1] = (y / len) * radius; | |
| out[i3 + 2] = (z / len) * radius; | |
| } | |
| } | |
| function computeOctaPositions(orig, out, count, radius) { | |
| for (let i = 0; i < count; i++) { | |
| const i3 = i * 3; | |
| const x = orig[i3], y = orig[i3 + 1], z = orig[i3 + 2]; | |
| const ax = Math.abs(x), ay = Math.abs(y), az = Math.abs(z); | |
| const max = Math.max(ax, ay, az) || 1; | |
| const sx = x >= 0 ? 1 : -1, sy = y >= 0 ? 1 : -1, sz = z >= 0 ? 1 : -1; | |
| const blend = 0.65; | |
| out[i3] = x * (1 - blend) + (sx * radius * (ax / max)) * blend; | |
| out[i3 + 1] = y * (1 - blend) + (sy * radius * (ay / max)) * blend; | |
| out[i3 + 2] = z * (1 - blend) + (sz * radius * (az / max)) * blend; | |
| } | |
| } | |
| computeSpherePositions(icoPosOrig, icoPosSphere, icoVertCount, ICOSA_RADIUS); | |
| computeOctaPositions(icoPosOrig, icoPosOcta, icoVertCount, ICOSA_RADIUS); | |
| // ─── Flythrough ───────────────────────────────────────────────────── | |
| const flythrough = { active: false, t: 0 }; | |
| // ─── Edge-Flow Particles ───────────────────────────────────────────── | |
| function getEdgeList(geo) { | |
| const idx = geo.index; | |
| const pos = geo.attributes.position; | |
| const set = new Set(); | |
| const k = (x, y) => `${Math.min(x,y)},${Math.max(x,y)}`; | |
| if (idx) { | |
| for (let i = 0; i < idx.count; i += 3) { | |
| const a = idx.getX(i), b = idx.getX(i + 1), c = idx.getX(i + 2); | |
| set.add(k(a, b)); set.add(k(b, c)); set.add(k(c, a)); | |
| } | |
| } else { | |
| for (let i = 0; i < pos.count; i += 3) { | |
| set.add(k(i, i + 1)); set.add(k(i + 1, i + 2)); set.add(k(i + 2, i)); | |
| } | |
| } | |
| return Array.from(set).map(s => s.split(',').map(Number)); | |
| } | |
| const icoEdges = getEdgeList(icoGeo); | |
| const knotEdges = getEdgeList(knotGeo); | |
| const ICO_MAX = 3000; | |
| const KNOT_MAX = 2000; | |
| const icoPPos = new Float32Array(ICO_MAX * 3); | |
| const icoPCol = new Float32Array(ICO_MAX * 3); | |
| const icoPartState = []; | |
| for (let i = 0; i < ICO_MAX; i++) { | |
| icoPartState.push({ edge: Math.floor(Math.random() * icoEdges.length), t: Math.random(), speed: 0.15 + Math.random() * 0.4, dir: Math.random() < 0.5 ? 1 : -1 }); | |
| } | |
| const knotPPos = new Float32Array(KNOT_MAX * 3); | |
| const knotPCol = new Float32Array(KNOT_MAX * 3); | |
| const knotPartState = []; | |
| for (let i = 0; i < KNOT_MAX; i++) { | |
| knotPartState.push({ edge: Math.floor(Math.random() * knotEdges.length), t: Math.random(), speed: 0.15 + Math.random() * 0.4, dir: Math.random() < 0.5 ? 1 : -1 }); | |
| } | |
| const icoPGeo = new THREE.BufferGeometry(); | |
| icoPGeo.setAttribute('position', new THREE.BufferAttribute(icoPPos, 3)); | |
| icoPGeo.setAttribute('color', new THREE.BufferAttribute(icoPCol, 3)); | |
| icoPGeo.setDrawRange(0, 0); | |
| const knotPGeo = new THREE.BufferGeometry(); | |
| knotPGeo.setAttribute('position', new THREE.BufferAttribute(knotPPos, 3)); | |
| knotPGeo.setAttribute('color', new THREE.BufferAttribute(knotPCol, 3)); | |
| knotPGeo.setDrawRange(0, 0); | |
| function makeGlowTex() { | |
| const c = document.createElement('canvas'); | |
| c.width = 32; c.height = 32; | |
| const ctx = c.getContext('2d'); | |
| const g = ctx.createRadialGradient(16, 16, 0, 16, 16, 16); | |
| g.addColorStop(0, 'rgba(255,255,240,1)'); | |
| g.addColorStop(0.3, 'rgba(220,210,200,0.5)'); | |
| g.addColorStop(1, 'rgba(255,255,255,0)'); | |
| ctx.fillStyle = g; ctx.fillRect(0, 0, 32, 32); | |
| return new THREE.CanvasTexture(c); | |
| } | |
| const glowTex = makeGlowTex(); | |
| const ptMat = new THREE.PointsMaterial({ | |
| size: 0.13, map: glowTex, blending: THREE.AdditiveBlending, | |
| depthWrite: false, transparent: true, opacity: 0.85, | |
| sizeAttenuation: true, vertexColors: true, | |
| }); | |
| icoMesh1.add(new THREE.Points(icoPGeo, ptMat.clone())); | |
| knotMesh1.add(new THREE.Points(knotPGeo, ptMat.clone())); | |
| const icoCol = [0.78, 0.72, 0.56]; | |
| const knotCol = [0.48, 0.60, 0.67]; | |
| // ─── Starfield ──────────────────────────────────────────────────────── | |
| const starCount = 2500; | |
| const starGeo = new THREE.BufferGeometry(); | |
| const starPos = new Float32Array(starCount * 3); | |
| for (let i = 0; i < starCount * 3; i++) { | |
| starPos[i] = (Math.random() - 0.5) * 280; | |
| } | |
| starGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3)); | |
| const starMat = new THREE.PointsMaterial({ | |
| color: 0x8888aa, | |
| size: 0.08, | |
| transparent: true, | |
| opacity: 0.35, | |
| sizeAttenuation: true, | |
| }); | |
| scene.add(new THREE.Points(starGeo, starMat)); | |
| // ─── Icosahedron Pulse ──────────────────────────────────────────────── | |
| function icoPulse(pos, orig, count, time, amp, speed) { | |
| const a = 0.4 * amp; | |
| const f = 1.2; | |
| for (let i = 0; i < count; i++) { | |
| const i3 = i * 3; | |
| const ox = orig[i3], oy = orig[i3 + 1], oz = orig[i3 + 2]; | |
| const wave = Math.sin(ox * 0.35 + oy * 0.25 + oz * 0.30 + time * 0.6 * speed); | |
| const scale = 1 + a * wave; | |
| pos[i3] = ox * scale; | |
| pos[i3 + 1] = oy * scale; | |
| pos[i3 + 2] = oz * scale; | |
| } | |
| } | |
| // ─── Torus Knot Morph ──────────────────────────────────────────────── | |
| function knotMorph(pos, orig, count, time, amp, speed) { | |
| const a = 0.15 * amp; | |
| const f = 0.8; | |
| for (let i = 0; i < count; i++) { | |
| const i3 = i * 3; | |
| const ox = orig[i3], oy = orig[i3 + 1], oz = orig[i3 + 2]; | |
| const wave = Math.sin(ox * 0.5 + oy * 0.4 + oz * 0.3 + time * 0.5 * speed); | |
| const scale = 1 + a * wave; | |
| pos[i3] = ox * scale; | |
| pos[i3 + 1] = oy * scale; | |
| pos[i3 + 2] = oz * scale; | |
| } | |
| } | |
| // ─── Update Edge Particles ────────────────────────────────────────── | |
| function updateEdgeParticles(srcPos, edges, state, posArr, colArr, colBase, dt, mul, outGeo, count) { | |
| const edgeCount = edges.length; | |
| if (edgeCount === 0 || count === 0) return; | |
| for (let i = 0; i < count; i++) { | |
| const s = state[i]; | |
| s.t += s.speed * dt * mul * s.dir; | |
| if (s.dir === 1 && s.t >= 1) { | |
| s.t = 0; s.edge = Math.floor(Math.random() * edgeCount); | |
| if (Math.random() < 0.5) s.dir = -1; | |
| } else if (s.dir === -1 && s.t <= 0) { | |
| s.t = 1; s.edge = Math.floor(Math.random() * edgeCount); | |
| if (Math.random() < 0.5) s.dir = 1; | |
| } | |
| const edge = edges[s.edge]; | |
| if (!edge) continue; | |
| const [a, b] = edge; | |
| const i3 = i * 3, ai = a * 3, bi = b * 3; | |
| const t = s.t; | |
| posArr[i3] = srcPos[ai] + (srcPos[bi] - srcPos[ai]) * t; | |
| posArr[i3 + 1] = srcPos[ai + 1] + (srcPos[bi + 1] - srcPos[ai + 1]) * t; | |
| posArr[i3 + 2] = srcPos[ai + 2] + (srcPos[bi + 2] - srcPos[ai + 2]) * t; | |
| const bright = s.dir === 1 ? (0.25 + t * 0.75) : (0.25 + (1 - t) * 0.75); | |
| colArr[i3] = colBase[0] * bright; | |
| colArr[i3 + 1] = colBase[1] * bright; | |
| colArr[i3 + 2] = colBase[2] * bright; | |
| } | |
| outGeo.attributes.position.needsUpdate = true; | |
| outGeo.attributes.color.needsUpdate = true; | |
| } | |
| // ─── Resize ─────────────────────────────────────────────────────────── | |
| window.addEventListener('resize', () => { | |
| camera.aspect = innerWidth / innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(innerWidth, innerHeight); | |
| }); | |
| // ─── Keydown ────────────────────────────────────────────────────────── | |
| window.addEventListener('keydown', (e) => { | |
| if (e.key === 'f' || e.key === 'F') { | |
| flythrough.active = !flythrough.active; | |
| controls.enabled = !flythrough.active; | |
| if (!flythrough.active) { | |
| controls.enabled = true; | |
| } | |
| } | |
| }); | |
| function exportState() { | |
| const p = camera.position; | |
| const vals = params; | |
| let html = document.documentElement.outerHTML; | |
| const ctrlStart = html.indexOf('<div id="controls">'); | |
| let depth = 0, end = ctrlStart; | |
| for (let i = ctrlStart; i < html.length; i++) { | |
| if (html.slice(i, i+4) === '<div') depth++; | |
| if (html.slice(i, i+5) === '</div') { depth--; if (depth === 0) { end = i + 6; break; } } | |
| } | |
| html = html.slice(0, ctrlStart) + html.slice(end); | |
| html = html.replace( | |
| 'camera.position.set(14, 8, 18)', | |
| `camera.position.set(${p.x.toFixed(2)}, ${p.y.toFixed(2)}, ${p.z.toFixed(2)})` | |
| ); | |
| for (const [key, val] of Object.entries(vals)) { | |
| const re = new RegExp(`(${key}:\\s*)[\\d.]+`); | |
| html = html.replace(re, `$1${val}`); | |
| } | |
| html = html.replace(/^\s*setupSlider\(.*\n/gm, ''); | |
| const blob = new Blob([html], { type: 'text/html' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = 'wireframe-scene.html'; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| } | |
| document.getElementById('export-btn').addEventListener('click', exportState); | |
| const fsBtn = document.getElementById('fullscreen-btn'); | |
| fsBtn.addEventListener('click', () => { | |
| if (!document.fullscreenElement) { | |
| document.documentElement.requestFullscreen().catch(() => {}); | |
| } else { | |
| document.exitFullscreen().catch(() => {}); | |
| } | |
| }); | |
| // ─── Params & Sliders ───────────────────────────────────────────────── | |
| const params = { | |
| waveHeight: 1.0, | |
| waveSpeed: 1.0, | |
| icoCount: 500, | |
| knotCount: 250, | |
| particleSize: 0.13, | |
| flowSpeed: 1.0, | |
| morphTarget: 0.0, | |
| }; | |
| function setupSlider(id, key, fmt) { | |
| const input = document.getElementById(id); | |
| const display = document.getElementById('val-' + id); | |
| if (!input || !display) return; | |
| const update = () => { | |
| params[key] = parseFloat(input.value); | |
| display.textContent = fmt ? fmt(params[key]) : params[key].toFixed(2); | |
| }; | |
| input.addEventListener('input', update); | |
| update(); | |
| } | |
| setupSlider('waveHeight', 'waveHeight'); | |
| setupSlider('waveSpeed', 'waveSpeed'); | |
| setupSlider('icoCount', 'icoCount', v => Math.round(v)); | |
| setupSlider('knotCount', 'knotCount', v => Math.round(v)); | |
| setupSlider('particleSize', 'particleSize', v => v.toFixed(2)); | |
| setupSlider('flowSpeed', 'flowSpeed'); | |
| setupSlider('morphTarget', 'morphTarget', v => v.toFixed(2)); | |
| // ─── Animation Loop ─────────────────────────────────────────────────── | |
| let lastTime = performance.now(); | |
| let time = 0; | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| const now = performance.now(); | |
| const dt = Math.min((now - lastTime) / 1000, 0.05); | |
| lastTime = now; | |
| time += dt; | |
| // Geometry morphing (Icosahedron: Icosa <-> Sphere <-> Octa) | |
| const morphT = params.morphTarget; | |
| for (let i = 0; i < icoPosMorphed.length; i++) { | |
| if (morphT <= 1) { | |
| icoPosMorphed[i] = icoPosOrig[i] + (icoPosSphere[i] - icoPosOrig[i]) * morphT; | |
| } else { | |
| icoPosMorphed[i] = icoPosSphere[i] + (icoPosOcta[i] - icoPosSphere[i]) * (morphT - 1); | |
| } | |
| } | |
| // Pulse wireframe vertices | |
| const icoPos = icoGeo.attributes.position.array; | |
| const knotPos = knotGeo.attributes.position.array; | |
| icoPulse(icoPos, icoPosMorphed, icoGeo.attributes.position.count, time, params.waveHeight, params.waveSpeed); | |
| knotMorph(knotPos, knotPosOrig, knotGeo.attributes.position.count, time, params.waveHeight, params.waveSpeed); | |
| icoGeo.attributes.position.needsUpdate = true; | |
| knotGeo.attributes.position.needsUpdate = true; | |
| // Update edge-flow particles | |
| const icoN = Math.round(params.icoCount); | |
| const knotN = Math.round(params.knotCount); | |
| icoPGeo.setDrawRange(0, icoN); | |
| knotPGeo.setDrawRange(0, knotN); | |
| updateEdgeParticles(icoGeo.attributes.position.array, icoEdges, icoPartState, icoPPos, icoPCol, icoCol, dt, 0.5 * params.flowSpeed, icoPGeo, icoN); | |
| updateEdgeParticles(knotGeo.attributes.position.array, knotEdges, knotPartState, knotPPos, knotPCol, knotCol, dt, 0.4 * params.flowSpeed, knotPGeo, knotN); | |
| // Update particle size | |
| icoMesh1.children[0].material.size = params.particleSize; | |
| knotMesh1.children[0].material.size = params.particleSize; | |
| // Rotate structures | |
| icoMesh1.rotation.y += dt * 0.06; | |
| icoMesh1.rotation.x += dt * 0.02; | |
| icoMesh2.rotation.copy(icoMesh1.rotation); | |
| knotMesh1.rotation.y -= dt * 0.10; | |
| knotMesh1.rotation.x += dt * 0.03; | |
| knotMesh2.rotation.copy(knotMesh1.rotation); | |
| // Flythrough | |
| if (flythrough.active) { | |
| flythrough.t += dt * 0.04; | |
| const ft = flythrough.t; | |
| const sinT = Math.sin(ft); | |
| const cosT = Math.cos(ft); | |
| const denom = 1 + sinT * sinT; | |
| const fx = 16 * cosT / denom; | |
| const fy = 4 * Math.sin(2 * ft) / denom; | |
| const fz = 12 * sinT / denom; | |
| camera.position.set(fx, fy, fz); | |
| controls.target.set(0, 0, 0); | |
| controls.update(); | |
| } else { | |
| controls.target.set(0, 0, 0); | |
| controls.update(); | |
| } | |
| // Light orbit | |
| const angle = time * 0.03; | |
| key.position.x = 16 * Math.cos(angle); | |
| key.position.z = 16 * Math.sin(angle); | |
| renderer.render(scene, camera); | |
| } | |
| animate(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment