- Add BaseVisualizer class for shared visualizer logic - Add Helix visualizer (DNA helix with rungs, beat compression/bounce) - Add Tunnel visualizer (ring tunnel with depth parallax) - Refactor SphereVisualizer to extend BaseVisualizer - Add mouse tracking to all visualizers (canvas-relative, centered) - Add visualizer switching via logo click (persisted to localStorage) - Add beat-responsive bouncing and compression effects 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
202 lines
5.7 KiB
JavaScript
202 lines
5.7 KiB
JavaScript
/**
|
|
* DNA Helix Visualizer
|
|
* Immersive multi-layered double helix that fills the screen
|
|
*/
|
|
|
|
import * as THREE from 'three';
|
|
import { BaseVisualizer } from './base-visualizer.js';
|
|
|
|
const vertexShader = `
|
|
uniform float uTime;
|
|
uniform float uLow;
|
|
uniform float uMid;
|
|
uniform float uHigh;
|
|
uniform float uMouseX;
|
|
uniform float uMouseY;
|
|
|
|
attribute float aStrand; // 0 or 1 for which strand
|
|
attribute float aProgress; // 0-1 position along helix
|
|
attribute float aIsRung; // 1 if this is a connecting rung particle
|
|
attribute float aLayer; // Which helix layer (0, 1, 2 for nested helixes)
|
|
|
|
varying float vAlpha;
|
|
varying float vStrand;
|
|
varying float vLayer;
|
|
|
|
void main() {
|
|
vec3 pos = position;
|
|
|
|
// Layer-based radius - nested helixes
|
|
float baseRadius = 8.0 + aLayer * 12.0; // Inner: 8, Mid: 20, Outer: 32
|
|
float helixRadius = baseRadius + uLow * 6.0; // Bass expands all layers
|
|
float helixHeight = 100.0 * (1.0 - uLow * 0.3); // Compress with bass
|
|
float twists = 5.0 - aLayer * 1.0; // Inner twists more
|
|
|
|
// Rotation speed - layers rotate at different speeds
|
|
float layerSpeed = 1.0 - aLayer * 0.2;
|
|
float rotationSpeed = (0.6 + uMid * 2.0) * layerSpeed;
|
|
float timeOffset = uTime * rotationSpeed;
|
|
|
|
// Reverse rotation for alternate layers
|
|
if (mod(aLayer, 2.0) > 0.5) {
|
|
timeOffset = -timeOffset;
|
|
}
|
|
|
|
// Calculate helix position
|
|
float t = aProgress;
|
|
float angle = t * twists * 6.28318 + timeOffset;
|
|
|
|
// Offset for second strand
|
|
if (aStrand > 0.5 && aIsRung < 0.5) {
|
|
angle += 3.14159;
|
|
}
|
|
|
|
// Base helix position - along Z-axis (viewer inside helix)
|
|
float x = cos(angle) * helixRadius;
|
|
float y = sin(angle) * helixRadius;
|
|
float z = (t - 0.5) * helixHeight;
|
|
|
|
// Rungs connect the two strands
|
|
if (aIsRung > 0.5) {
|
|
float rungAngle = t * twists * 6.28318 + timeOffset;
|
|
float rungT = fract(aStrand * 10.0);
|
|
float angle1 = rungAngle;
|
|
float angle2 = rungAngle + 3.14159;
|
|
|
|
x = mix(cos(angle1), cos(angle2), rungT) * helixRadius;
|
|
y = mix(sin(angle1), sin(angle2), rungT) * helixRadius;
|
|
|
|
// Rungs pulse dramatically with beat
|
|
float pulse = 1.0 + uLow * 0.5;
|
|
x *= pulse;
|
|
y *= pulse;
|
|
}
|
|
|
|
// High frequencies add jitter/scatter - more on outer layers
|
|
float jitterAmount = uHigh * (3.0 + aLayer * 2.0);
|
|
x += sin(t * 40.0 + uTime * 8.0 + aLayer) * jitterAmount;
|
|
y += cos(t * 40.0 + uTime * 8.0) * jitterAmount * 0.5;
|
|
z += sin(t * 40.0 + uTime * 8.0 + 1.57 + aLayer) * jitterAmount;
|
|
|
|
// Mouse offset - helix follows mouse position subtly
|
|
x -= uMouseX * 1.0;
|
|
y -= uMouseY * 0.7;
|
|
|
|
// Bounce toward viewer with bass
|
|
z += uLow * 10.0;
|
|
|
|
pos = vec3(x, y, z);
|
|
|
|
vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
|
|
|
|
// Point size - larger for outer layers
|
|
float size = 3.0 + aLayer * 1.0;
|
|
if (aIsRung > 0.5) {
|
|
size = 2.0 + uLow * 1.5;
|
|
}
|
|
size *= (1.0 + uLow * 0.3);
|
|
gl_PointSize = size * (300.0 / -mvPosition.z);
|
|
|
|
// Alpha - outer layers slightly more transparent
|
|
vAlpha = 0.8 - aLayer * 0.1 + uLow * 0.2;
|
|
if (aIsRung > 0.5) {
|
|
vAlpha *= 0.7;
|
|
}
|
|
|
|
vStrand = aStrand;
|
|
vLayer = aLayer;
|
|
|
|
gl_Position = projectionMatrix * mvPosition;
|
|
}
|
|
`;
|
|
|
|
const fragmentShader = `
|
|
varying float vAlpha;
|
|
varying float vStrand;
|
|
varying float vLayer;
|
|
|
|
void main() {
|
|
float dist = length(gl_PointCoord - vec2(0.5));
|
|
if (dist > 0.5) discard;
|
|
|
|
float alpha = 1.0 - smoothstep(0.1, 0.5, dist);
|
|
alpha *= vAlpha;
|
|
|
|
// Pure white
|
|
vec3 color = vec3(1.0);
|
|
|
|
gl_FragColor = vec4(color, alpha);
|
|
}
|
|
`;
|
|
|
|
export class HelixVisualizer extends BaseVisualizer {
|
|
static name = 'Helix';
|
|
|
|
constructor(scene) {
|
|
super(scene);
|
|
this.particlesPerStrand = 400;
|
|
this.rungsCount = 60;
|
|
this.layers = 1; // Single helix
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
const rungsPerLayer = this.rungsCount;
|
|
const particlesPerRung = 15;
|
|
|
|
const totalParticles = this.layers * rungsPerLayer * particlesPerRung;
|
|
|
|
const positions = new Float32Array(totalParticles * 3);
|
|
const strands = new Float32Array(totalParticles);
|
|
const progress = new Float32Array(totalParticles);
|
|
const isRung = new Float32Array(totalParticles);
|
|
const layers = new Float32Array(totalParticles);
|
|
|
|
let idx = 0;
|
|
|
|
for (let layer = 0; layer < this.layers; layer++) {
|
|
// Rungs only (connecting particles between strands)
|
|
for (let r = 0; r < rungsPerLayer; r++) {
|
|
const rungProgress = r / rungsPerLayer;
|
|
|
|
for (let p = 0; p < particlesPerRung; p++) {
|
|
positions[idx * 3] = 0;
|
|
positions[idx * 3 + 1] = 0;
|
|
positions[idx * 3 + 2] = 0;
|
|
strands[idx] = p / (particlesPerRung - 1);
|
|
progress[idx] = rungProgress;
|
|
isRung[idx] = 1;
|
|
layers[idx] = layer;
|
|
idx++;
|
|
}
|
|
}
|
|
}
|
|
|
|
const geometry = new THREE.BufferGeometry();
|
|
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
geometry.setAttribute('aStrand', new THREE.BufferAttribute(strands, 1));
|
|
geometry.setAttribute('aProgress', new THREE.BufferAttribute(progress, 1));
|
|
geometry.setAttribute('aIsRung', new THREE.BufferAttribute(isRung, 1));
|
|
geometry.setAttribute('aLayer', new THREE.BufferAttribute(layers, 1));
|
|
|
|
this.material = this.createShaderMaterial(vertexShader, fragmentShader, {
|
|
uMouseX: { value: 0 },
|
|
uMouseY: { value: 0 }
|
|
});
|
|
this.mesh = new THREE.Points(geometry, this.material);
|
|
}
|
|
|
|
update(bands, time, mouse = { x: 0, y: 0 }) {
|
|
if (!this.material) return;
|
|
|
|
this.material.uniforms.uTime.value = time;
|
|
this.material.uniforms.uLow.value = bands.low || 0;
|
|
this.material.uniforms.uMid.value = bands.mid || 0;
|
|
this.material.uniforms.uHigh.value = bands.high || 0;
|
|
this.material.uniforms.uMouseX.value = mouse.x;
|
|
this.material.uniforms.uMouseY.value = mouse.y;
|
|
}
|
|
}
|
|
|
|
export default HelixVisualizer;
|