Files
pivoine.art/assets/js/visualizer/helix.js
Sebastian Krüger e50c8a2503 feat(visualizer): add multiple visualizers with mouse tracking and beat response
- 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>
2025-11-29 21:11:38 +01:00

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;