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>
This commit is contained in:
90
assets/js/visualizer/base-visualizer.js
Normal file
90
assets/js/visualizer/base-visualizer.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Base Visualizer Class
|
||||||
|
* Abstract base for all audio visualizers
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
export class BaseVisualizer {
|
||||||
|
constructor(scene) {
|
||||||
|
this.scene = scene;
|
||||||
|
this.mesh = null;
|
||||||
|
this.isVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize geometry, materials, and mesh
|
||||||
|
* Override in subclasses
|
||||||
|
*/
|
||||||
|
init() {
|
||||||
|
throw new Error('init() must be implemented by subclass');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update visualizer with audio data
|
||||||
|
* @param {Object} bands - { low, mid, high } frequency bands (0-1)
|
||||||
|
* @param {number} time - Animation time
|
||||||
|
*/
|
||||||
|
update(bands, time) {
|
||||||
|
throw new Error('update() must be implemented by subclass');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add visualizer to scene
|
||||||
|
*/
|
||||||
|
show() {
|
||||||
|
if (this.mesh && !this.isVisible) {
|
||||||
|
this.scene.add(this.mesh);
|
||||||
|
this.isVisible = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove visualizer from scene
|
||||||
|
*/
|
||||||
|
hide() {
|
||||||
|
if (this.mesh && this.isVisible) {
|
||||||
|
this.scene.remove(this.mesh);
|
||||||
|
this.isVisible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup resources
|
||||||
|
*/
|
||||||
|
dispose() {
|
||||||
|
this.hide();
|
||||||
|
if (this.mesh) {
|
||||||
|
this.mesh.geometry?.dispose();
|
||||||
|
if (this.mesh.material) {
|
||||||
|
if (Array.isArray(this.mesh.material)) {
|
||||||
|
this.mesh.material.forEach(m => m.dispose());
|
||||||
|
} else {
|
||||||
|
this.mesh.material.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Create shader material with common settings
|
||||||
|
*/
|
||||||
|
createShaderMaterial(vertexShader, fragmentShader, uniforms = {}) {
|
||||||
|
return new THREE.ShaderMaterial({
|
||||||
|
vertexShader,
|
||||||
|
fragmentShader,
|
||||||
|
uniforms: {
|
||||||
|
uTime: { value: 0 },
|
||||||
|
uLow: { value: 0 },
|
||||||
|
uMid: { value: 0 },
|
||||||
|
uHigh: { value: 0 },
|
||||||
|
...uniforms
|
||||||
|
},
|
||||||
|
transparent: true,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
depthWrite: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BaseVisualizer;
|
||||||
201
assets/js/visualizer/helix.js
Normal file
201
assets/js/visualizer/helix.js
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
@@ -1,17 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Particle System
|
* Particle System (Sphere) Visualizer
|
||||||
* Audio-reactive 3D particle cloud
|
* Audio-reactive 3D particle cloud
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
|
import { BaseVisualizer } from './base-visualizer.js';
|
||||||
|
|
||||||
// Vertex Shader
|
|
||||||
const vertexShader = `
|
const vertexShader = `
|
||||||
uniform float uTime;
|
uniform float uTime;
|
||||||
uniform float uLow;
|
uniform float uLow;
|
||||||
uniform float uMid;
|
uniform float uMid;
|
||||||
uniform float uHigh;
|
uniform float uHigh;
|
||||||
uniform float uSize;
|
uniform float uSize;
|
||||||
|
uniform float uMouseX;
|
||||||
|
uniform float uMouseY;
|
||||||
|
|
||||||
attribute float aRandom;
|
attribute float aRandom;
|
||||||
attribute float aScale;
|
attribute float aScale;
|
||||||
@@ -42,6 +44,10 @@ void main() {
|
|||||||
vec3 jitter = normalize(pos) * uHigh * aRandom * 5.0;
|
vec3 jitter = normalize(pos) * uHigh * aRandom * 5.0;
|
||||||
pos += jitter;
|
pos += jitter;
|
||||||
|
|
||||||
|
// Mouse offset
|
||||||
|
pos.x -= uMouseX * 3.0;
|
||||||
|
pos.y -= uMouseY * 2.0;
|
||||||
|
|
||||||
// Calculate view position
|
// Calculate view position
|
||||||
vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
|
vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
|
||||||
|
|
||||||
@@ -57,7 +63,6 @@ void main() {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Fragment Shader
|
|
||||||
const fragmentShader = `
|
const fragmentShader = `
|
||||||
varying float vAlpha;
|
varying float vAlpha;
|
||||||
|
|
||||||
@@ -74,17 +79,16 @@ void main() {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export class ParticleSystem {
|
export class SphereVisualizer extends BaseVisualizer {
|
||||||
constructor(count = 5000) {
|
static name = 'Sphere';
|
||||||
|
|
||||||
|
constructor(scene, count = 5000) {
|
||||||
|
super(scene);
|
||||||
this.count = count;
|
this.count = count;
|
||||||
this.createGeometry();
|
this.init();
|
||||||
this.createMaterial();
|
|
||||||
this.mesh = new THREE.Points(this.geometry, this.material);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createGeometry() {
|
init() {
|
||||||
this.geometry = new THREE.BufferGeometry();
|
|
||||||
|
|
||||||
const positions = new Float32Array(this.count * 3);
|
const positions = new Float32Array(this.count * 3);
|
||||||
const randoms = new Float32Array(this.count);
|
const randoms = new Float32Array(this.count);
|
||||||
const scales = new Float32Array(this.count);
|
const scales = new Float32Array(this.count);
|
||||||
@@ -105,41 +109,37 @@ export class ParticleSystem {
|
|||||||
scales[i] = 0.5 + Math.random() * 1.5;
|
scales[i] = 0.5 + Math.random() * 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
const geometry = new THREE.BufferGeometry();
|
||||||
this.geometry.setAttribute('aRandom', new THREE.BufferAttribute(randoms, 1));
|
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||||
this.geometry.setAttribute('aScale', new THREE.BufferAttribute(scales, 1));
|
geometry.setAttribute('aRandom', new THREE.BufferAttribute(randoms, 1));
|
||||||
}
|
geometry.setAttribute('aScale', new THREE.BufferAttribute(scales, 1));
|
||||||
|
|
||||||
createMaterial() {
|
this.material = this.createShaderMaterial(vertexShader, fragmentShader, {
|
||||||
this.material = new THREE.ShaderMaterial({
|
uSize: { value: 2.0 },
|
||||||
vertexShader,
|
uMouseX: { value: 0 },
|
||||||
fragmentShader,
|
uMouseY: { value: 0 }
|
||||||
uniforms: {
|
|
||||||
uTime: { value: 0 },
|
|
||||||
uLow: { value: 0 },
|
|
||||||
uMid: { value: 0 },
|
|
||||||
uHigh: { value: 0 },
|
|
||||||
uSize: { value: 2.0 }
|
|
||||||
},
|
|
||||||
transparent: true,
|
|
||||||
blending: THREE.AdditiveBlending,
|
|
||||||
depthWrite: false
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.mesh = new THREE.Points(geometry, this.material);
|
||||||
}
|
}
|
||||||
|
|
||||||
update(bands, time) {
|
update(bands, time, mouse = { x: 0, y: 0 }) {
|
||||||
if (!this.material) return;
|
if (!this.material) return;
|
||||||
|
|
||||||
this.material.uniforms.uTime.value = time;
|
this.material.uniforms.uTime.value = time;
|
||||||
this.material.uniforms.uLow.value = bands.low || 0;
|
this.material.uniforms.uLow.value = bands.low || 0;
|
||||||
this.material.uniforms.uMid.value = bands.mid || 0;
|
this.material.uniforms.uMid.value = bands.mid || 0;
|
||||||
this.material.uniforms.uHigh.value = bands.high || 0;
|
this.material.uniforms.uHigh.value = bands.high || 0;
|
||||||
}
|
this.material.uniforms.uMouseX.value = mouse.x;
|
||||||
|
this.material.uniforms.uMouseY.value = mouse.y;
|
||||||
dispose() {
|
|
||||||
this.geometry?.dispose();
|
|
||||||
this.material?.dispose();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ParticleSystem;
|
// Legacy export for backwards compatibility
|
||||||
|
export class ParticleSystem extends SphereVisualizer {
|
||||||
|
constructor(count = 5000) {
|
||||||
|
super(null, count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SphereVisualizer;
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* WebGL Visualizer Scene
|
* WebGL Visualizer Scene
|
||||||
* Three.js particle system that reacts to audio frequency data
|
* Three.js scene manager with multiple selectable visualizers
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { ParticleSystem } from './particles.js';
|
import { SphereVisualizer } from './particles.js';
|
||||||
|
import { TunnelVisualizer } from './tunnel.js';
|
||||||
|
import { HelixVisualizer } from './helix.js';
|
||||||
|
|
||||||
|
// Available visualizer classes
|
||||||
|
const VISUALIZERS = [
|
||||||
|
SphereVisualizer,
|
||||||
|
TunnelVisualizer,
|
||||||
|
HelixVisualizer
|
||||||
|
];
|
||||||
|
|
||||||
export class Visualizer {
|
export class Visualizer {
|
||||||
constructor(canvas, audioManager) {
|
constructor(canvas, audioManager) {
|
||||||
@@ -13,6 +22,14 @@ export class Visualizer {
|
|||||||
this.running = false;
|
this.running = false;
|
||||||
this.time = 0;
|
this.time = 0;
|
||||||
|
|
||||||
|
// Mouse tracking (normalized -1 to 1)
|
||||||
|
this.mouse = { x: 0, y: 0 };
|
||||||
|
this.mouseActive = false;
|
||||||
|
|
||||||
|
// Visualizer management
|
||||||
|
this.visualizers = [];
|
||||||
|
this.currentIndex = 0;
|
||||||
|
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
console.warn('Visualizer: No canvas provided');
|
console.warn('Visualizer: No canvas provided');
|
||||||
return;
|
return;
|
||||||
@@ -44,17 +61,93 @@ export class Visualizer {
|
|||||||
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
||||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
|
||||||
// Particles
|
// Initialize all visualizers
|
||||||
this.particles = new ParticleSystem(5000);
|
this.visualizers = VISUALIZERS.map(VisualizerClass => {
|
||||||
this.scene.add(this.particles.mesh);
|
return new VisualizerClass(this.scene);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load saved visualizer preference
|
||||||
|
const savedIndex = localStorage.getItem('pivoine-visualizer');
|
||||||
|
if (savedIndex !== null) {
|
||||||
|
this.currentIndex = parseInt(savedIndex, 10) % this.visualizers.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the current visualizer
|
||||||
|
this.visualizers[this.currentIndex].show();
|
||||||
|
|
||||||
// Event listeners
|
// Event listeners
|
||||||
window.addEventListener('resize', () => this.resize());
|
window.addEventListener('resize', () => this.resize());
|
||||||
|
window.addEventListener('mousemove', (e) => this.onMouseMove(e));
|
||||||
|
|
||||||
// Start animation
|
// Start animation
|
||||||
this.start();
|
this.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of visualizer names
|
||||||
|
*/
|
||||||
|
getVisualizerNames() {
|
||||||
|
return VISUALIZERS.map(V => V.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current visualizer name
|
||||||
|
*/
|
||||||
|
getCurrentName() {
|
||||||
|
return VISUALIZERS[this.currentIndex].name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current visualizer index
|
||||||
|
*/
|
||||||
|
getCurrentIndex() {
|
||||||
|
return this.currentIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set active visualizer by index
|
||||||
|
*/
|
||||||
|
setVisualizer(index) {
|
||||||
|
if (index < 0 || index >= this.visualizers.length) return;
|
||||||
|
if (index === this.currentIndex) return;
|
||||||
|
|
||||||
|
// Hide current
|
||||||
|
this.visualizers[this.currentIndex].hide();
|
||||||
|
|
||||||
|
// Show new
|
||||||
|
this.currentIndex = index;
|
||||||
|
this.visualizers[this.currentIndex].show();
|
||||||
|
|
||||||
|
// Save preference
|
||||||
|
localStorage.setItem('pivoine-visualizer', index.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cycle to next visualizer
|
||||||
|
*/
|
||||||
|
nextVisualizer() {
|
||||||
|
const nextIndex = (this.currentIndex + 1) % this.visualizers.length;
|
||||||
|
this.setVisualizer(nextIndex);
|
||||||
|
return this.getCurrentName();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cycle to previous visualizer
|
||||||
|
*/
|
||||||
|
prevVisualizer() {
|
||||||
|
const prevIndex = (this.currentIndex - 1 + this.visualizers.length) % this.visualizers.length;
|
||||||
|
this.setVisualizer(prevIndex);
|
||||||
|
return this.getCurrentName();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMouseMove(e) {
|
||||||
|
// Normalize to -1 to 1 relative to canvas
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||||||
|
this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
||||||
|
this.mouseActive = true;
|
||||||
|
}
|
||||||
|
|
||||||
resize() {
|
resize() {
|
||||||
if (!this.camera || !this.renderer) return;
|
if (!this.camera || !this.renderer) return;
|
||||||
|
|
||||||
@@ -89,8 +182,12 @@ export class Visualizer {
|
|||||||
bands = this.audioManager.getFrequencyBands();
|
bands = this.audioManager.getFrequencyBands();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update particles with audio data
|
// Update current visualizer with audio data and mouse position
|
||||||
this.particles.update(bands, this.time);
|
const currentViz = this.visualizers[this.currentIndex];
|
||||||
|
if (currentViz) {
|
||||||
|
const mouse = this.mouseActive ? this.mouse : { x: 0, y: 0 };
|
||||||
|
currentViz.update(bands, this.time, mouse);
|
||||||
|
}
|
||||||
|
|
||||||
// Subtle camera movement
|
// Subtle camera movement
|
||||||
this.camera.position.x = Math.sin(this.time * 0.1) * 3;
|
this.camera.position.x = Math.sin(this.time * 0.1) * 3;
|
||||||
@@ -102,7 +199,7 @@ export class Visualizer {
|
|||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
this.running = false;
|
this.running = false;
|
||||||
this.particles?.dispose();
|
this.visualizers.forEach(v => v.dispose());
|
||||||
this.renderer?.dispose();
|
this.renderer?.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
146
assets/js/visualizer/tunnel.js
Normal file
146
assets/js/visualizer/tunnel.js
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Waveform Tunnel Visualizer
|
||||||
|
* Infinite tunnel of pulsing rings
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 aRingIndex;
|
||||||
|
attribute float aAngle;
|
||||||
|
|
||||||
|
varying float vAlpha;
|
||||||
|
varying float vRingIndex;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec3 pos = position;
|
||||||
|
|
||||||
|
// Ring depth position (z movement for tunnel effect)
|
||||||
|
float depth = mod(pos.z + uTime * 12.0, 200.0) - 100.0;
|
||||||
|
pos.z = depth;
|
||||||
|
|
||||||
|
// Distance from camera affects ring behavior
|
||||||
|
float depthFactor = 1.0 - (depth + 100.0) / 200.0;
|
||||||
|
|
||||||
|
// Bass expands rings - stronger pulse
|
||||||
|
float bassExpand = 1.0 + uLow * 0.8 * depthFactor;
|
||||||
|
pos.x *= bassExpand;
|
||||||
|
pos.y *= bassExpand;
|
||||||
|
|
||||||
|
// Bounce toward viewer on bass
|
||||||
|
pos.z += uLow * 15.0;
|
||||||
|
|
||||||
|
// Mid creates wave distortion on ring
|
||||||
|
float wave = sin(aAngle * 4.0 + uTime * 3.0) * uMid * 2.0 * depthFactor;
|
||||||
|
float waveRadius = 1.0 + wave * 0.3;
|
||||||
|
pos.x *= waveRadius;
|
||||||
|
pos.y *= waveRadius;
|
||||||
|
|
||||||
|
// High adds subtle jitter
|
||||||
|
pos.x += sin(aAngle * 8.0 + uTime * 5.0) * uHigh * 0.5;
|
||||||
|
pos.y += cos(aAngle * 8.0 + uTime * 5.0) * uHigh * 0.5;
|
||||||
|
|
||||||
|
// Mouse offset - stronger for closer rings
|
||||||
|
pos.x -= uMouseX * 5.0 * depthFactor;
|
||||||
|
pos.y -= uMouseY * 4.0 * depthFactor;
|
||||||
|
|
||||||
|
vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
|
||||||
|
|
||||||
|
// Point size based on depth - larger for more coverage
|
||||||
|
float size = 4.5 * (1.0 + uLow * 0.4);
|
||||||
|
gl_PointSize = size * (250.0 / -mvPosition.z);
|
||||||
|
|
||||||
|
// Alpha fades with depth
|
||||||
|
vAlpha = 0.8 * depthFactor + uHigh * 0.2;
|
||||||
|
vRingIndex = aRingIndex;
|
||||||
|
|
||||||
|
gl_Position = projectionMatrix * mvPosition;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const fragmentShader = `
|
||||||
|
varying float vAlpha;
|
||||||
|
varying float vRingIndex;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Slight color variation based on ring
|
||||||
|
vec3 color = vec3(1.0, 1.0, 1.0);
|
||||||
|
|
||||||
|
gl_FragColor = vec4(color, alpha);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export class TunnelVisualizer extends BaseVisualizer {
|
||||||
|
static name = 'Tunnel';
|
||||||
|
|
||||||
|
constructor(scene) {
|
||||||
|
super(scene);
|
||||||
|
this.ringCount = 100;
|
||||||
|
this.pointsPerRing = 128;
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
const totalPoints = this.ringCount * this.pointsPerRing;
|
||||||
|
const positions = new Float32Array(totalPoints * 3);
|
||||||
|
const ringIndices = new Float32Array(totalPoints);
|
||||||
|
const angles = new Float32Array(totalPoints);
|
||||||
|
|
||||||
|
let idx = 0;
|
||||||
|
for (let ring = 0; ring < this.ringCount; ring++) {
|
||||||
|
const z = (ring / this.ringCount) * 200 - 100; // -100 to 100
|
||||||
|
const radius = 28 + Math.sin(ring * 0.25) * 5; // Larger varying radius
|
||||||
|
|
||||||
|
for (let p = 0; p < this.pointsPerRing; p++) {
|
||||||
|
const angle = (p / this.pointsPerRing) * Math.PI * 2;
|
||||||
|
|
||||||
|
positions[idx * 3] = Math.cos(angle) * radius;
|
||||||
|
positions[idx * 3 + 1] = Math.sin(angle) * radius;
|
||||||
|
positions[idx * 3 + 2] = z;
|
||||||
|
|
||||||
|
ringIndices[idx] = ring / this.ringCount;
|
||||||
|
angles[idx] = angle;
|
||||||
|
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const geometry = new THREE.BufferGeometry();
|
||||||
|
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||||
|
geometry.setAttribute('aRingIndex', new THREE.BufferAttribute(ringIndices, 1));
|
||||||
|
geometry.setAttribute('aAngle', new THREE.BufferAttribute(angles, 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 TunnelVisualizer;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
description = "Valknar's audio"
|
description = "Valknar's Pivoine.Art"
|
||||||
author = "Valknar"
|
author = "Valknar"
|
||||||
email = "valknar@pivoine.art"
|
email = "valknar@pivoine.art"
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
---
|
---
|
||||||
title: "Valknar's"
|
title: "Valknar's"
|
||||||
description: "Valknar's audio"
|
description: "Valknar's Pivoine.Art"
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -64,6 +64,18 @@
|
|||||||
|
|
||||||
// Player UI component
|
// Player UI component
|
||||||
Alpine.data('playerUI', () => ({
|
Alpine.data('playerUI', () => ({
|
||||||
|
visualizerName: 'Sphere',
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Get initial visualizer name
|
||||||
|
this.$nextTick(() => {
|
||||||
|
const viz = window.__pivoine?.visualizer;
|
||||||
|
if (viz) {
|
||||||
|
this.visualizerName = viz.getCurrentName() || 'Sphere';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
togglePlay() {
|
togglePlay() {
|
||||||
window.__pivoine?.audioManager?.toggle();
|
window.__pivoine?.audioManager?.toggle();
|
||||||
},
|
},
|
||||||
@@ -85,6 +97,12 @@
|
|||||||
this.setVolume(this._previousVolume || 0.8);
|
this.setVolume(this._previousVolume || 0.8);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
cycleVisualizer() {
|
||||||
|
const viz = window.__pivoine?.visualizer;
|
||||||
|
if (viz) {
|
||||||
|
this.visualizerName = viz.nextVisualizer();
|
||||||
|
}
|
||||||
|
},
|
||||||
formatTime(seconds) {
|
formatTime(seconds) {
|
||||||
if (!seconds || isNaN(seconds)) return '0:00';
|
if (!seconds || isNaN(seconds)) return '0:00';
|
||||||
const mins = Math.floor(seconds / 60);
|
const mins = Math.floor(seconds / 60);
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
<header class="fixed top-0 left-0 right-0 z-sticky bg-surface-0/80 backdrop-blur-md border-b border-border">
|
<header class="fixed top-0 left-0 right-0 z-sticky bg-surface-0/80 backdrop-blur-md border-b border-border">
|
||||||
<nav class="container-wide flex items-center justify-between h-16">
|
<nav class="container-wide flex items-center justify-between h-16">
|
||||||
{{/* Logo */}}
|
{{/* Logo */}}
|
||||||
<a
|
<div class="flex items-center gap-3 group">
|
||||||
href="{{ "/" | relURL }}"
|
|
||||||
class="flex items-center gap-3 group"
|
|
||||||
aria-label="{{ .Site.Title }} - Home"
|
|
||||||
>
|
|
||||||
<canvas
|
<canvas
|
||||||
id="logo-canvas"
|
id="logo-canvas"
|
||||||
hx-preserve="true"
|
hx-preserve="true"
|
||||||
class="w-8 h-8"
|
class="w-8 h-8 cursor-pointer"
|
||||||
width="32"
|
width="32"
|
||||||
height="32"
|
height="32"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
@click="window.__pivoine?.visualizer?.nextVisualizer()"
|
||||||
|
title="Click to change visualizer"
|
||||||
></canvas>
|
></canvas>
|
||||||
<span class="text-lg font-medium tracking-tight group-hover:text-accent transition-colors">
|
<a
|
||||||
|
href="{{ "/" | relURL }}"
|
||||||
|
class="text-lg font-medium tracking-tight group-hover:text-accent transition-colors"
|
||||||
|
aria-label="{{ .Site.Title }} - Home"
|
||||||
|
>
|
||||||
VALKNAR'S
|
VALKNAR'S
|
||||||
</span>
|
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{/* Navigation */}}
|
{{/* Navigation */}}
|
||||||
<ul
|
<ul
|
||||||
|
|||||||
Reference in New Issue
Block a user