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
|
||||
*/
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { BaseVisualizer } from './base-visualizer.js';
|
||||
|
||||
// Vertex Shader
|
||||
const vertexShader = `
|
||||
uniform float uTime;
|
||||
uniform float uLow;
|
||||
uniform float uMid;
|
||||
uniform float uHigh;
|
||||
uniform float uSize;
|
||||
uniform float uMouseX;
|
||||
uniform float uMouseY;
|
||||
|
||||
attribute float aRandom;
|
||||
attribute float aScale;
|
||||
@@ -42,6 +44,10 @@ void main() {
|
||||
vec3 jitter = normalize(pos) * uHigh * aRandom * 5.0;
|
||||
pos += jitter;
|
||||
|
||||
// Mouse offset
|
||||
pos.x -= uMouseX * 3.0;
|
||||
pos.y -= uMouseY * 2.0;
|
||||
|
||||
// Calculate view position
|
||||
vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
|
||||
|
||||
@@ -57,7 +63,6 @@ void main() {
|
||||
}
|
||||
`;
|
||||
|
||||
// Fragment Shader
|
||||
const fragmentShader = `
|
||||
varying float vAlpha;
|
||||
|
||||
@@ -74,17 +79,16 @@ void main() {
|
||||
}
|
||||
`;
|
||||
|
||||
export class ParticleSystem {
|
||||
constructor(count = 5000) {
|
||||
export class SphereVisualizer extends BaseVisualizer {
|
||||
static name = 'Sphere';
|
||||
|
||||
constructor(scene, count = 5000) {
|
||||
super(scene);
|
||||
this.count = count;
|
||||
this.createGeometry();
|
||||
this.createMaterial();
|
||||
this.mesh = new THREE.Points(this.geometry, this.material);
|
||||
this.init();
|
||||
}
|
||||
|
||||
createGeometry() {
|
||||
this.geometry = new THREE.BufferGeometry();
|
||||
|
||||
init() {
|
||||
const positions = new Float32Array(this.count * 3);
|
||||
const randoms = 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;
|
||||
}
|
||||
|
||||
this.geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
this.geometry.setAttribute('aRandom', new THREE.BufferAttribute(randoms, 1));
|
||||
this.geometry.setAttribute('aScale', new THREE.BufferAttribute(scales, 1));
|
||||
}
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute('aRandom', new THREE.BufferAttribute(randoms, 1));
|
||||
geometry.setAttribute('aScale', new THREE.BufferAttribute(scales, 1));
|
||||
|
||||
createMaterial() {
|
||||
this.material = new THREE.ShaderMaterial({
|
||||
vertexShader,
|
||||
fragmentShader,
|
||||
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.material = this.createShaderMaterial(vertexShader, fragmentShader, {
|
||||
uSize: { value: 2.0 },
|
||||
uMouseX: { value: 0 },
|
||||
uMouseY: { value: 0 }
|
||||
});
|
||||
|
||||
this.mesh = new THREE.Points(geometry, this.material);
|
||||
}
|
||||
|
||||
update(bands, time) {
|
||||
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;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.geometry?.dispose();
|
||||
this.material?.dispose();
|
||||
this.material.uniforms.uMouseX.value = mouse.x;
|
||||
this.material.uniforms.uMouseY.value = mouse.y;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
* Three.js particle system that reacts to audio frequency data
|
||||
* Three.js scene manager with multiple selectable visualizers
|
||||
*/
|
||||
|
||||
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 {
|
||||
constructor(canvas, audioManager) {
|
||||
@@ -13,6 +22,14 @@ export class Visualizer {
|
||||
this.running = false;
|
||||
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) {
|
||||
console.warn('Visualizer: No canvas provided');
|
||||
return;
|
||||
@@ -44,17 +61,93 @@ export class Visualizer {
|
||||
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
|
||||
// Particles
|
||||
this.particles = new ParticleSystem(5000);
|
||||
this.scene.add(this.particles.mesh);
|
||||
// Initialize all visualizers
|
||||
this.visualizers = VISUALIZERS.map(VisualizerClass => {
|
||||
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
|
||||
window.addEventListener('resize', () => this.resize());
|
||||
window.addEventListener('mousemove', (e) => this.onMouseMove(e));
|
||||
|
||||
// Start animation
|
||||
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() {
|
||||
if (!this.camera || !this.renderer) return;
|
||||
|
||||
@@ -89,8 +182,12 @@ export class Visualizer {
|
||||
bands = this.audioManager.getFrequencyBands();
|
||||
}
|
||||
|
||||
// Update particles with audio data
|
||||
this.particles.update(bands, this.time);
|
||||
// Update current visualizer with audio data and mouse position
|
||||
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
|
||||
this.camera.position.x = Math.sin(this.time * 0.1) * 3;
|
||||
@@ -102,7 +199,7 @@ export class Visualizer {
|
||||
|
||||
destroy() {
|
||||
this.running = false;
|
||||
this.particles?.dispose();
|
||||
this.visualizers.forEach(v => v.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"
|
||||
email = "valknar@pivoine.art"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
---
|
||||
title: "Valknar's"
|
||||
description: "Valknar's audio"
|
||||
description: "Valknar's Pivoine.Art"
|
||||
---
|
||||
|
||||
@@ -64,6 +64,18 @@
|
||||
|
||||
// Player UI component
|
||||
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() {
|
||||
window.__pivoine?.audioManager?.toggle();
|
||||
},
|
||||
@@ -85,6 +97,12 @@
|
||||
this.setVolume(this._previousVolume || 0.8);
|
||||
}
|
||||
},
|
||||
cycleVisualizer() {
|
||||
const viz = window.__pivoine?.visualizer;
|
||||
if (viz) {
|
||||
this.visualizerName = viz.nextVisualizer();
|
||||
}
|
||||
},
|
||||
formatTime(seconds) {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
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">
|
||||
<nav class="container-wide flex items-center justify-between h-16">
|
||||
{{/* Logo */}}
|
||||
<a
|
||||
href="{{ "/" | relURL }}"
|
||||
class="flex items-center gap-3 group"
|
||||
aria-label="{{ .Site.Title }} - Home"
|
||||
>
|
||||
<div class="flex items-center gap-3 group">
|
||||
<canvas
|
||||
id="logo-canvas"
|
||||
hx-preserve="true"
|
||||
class="w-8 h-8"
|
||||
class="w-8 h-8 cursor-pointer"
|
||||
width="32"
|
||||
height="32"
|
||||
aria-hidden="true"
|
||||
@click="window.__pivoine?.visualizer?.nextVisualizer()"
|
||||
title="Click to change visualizer"
|
||||
></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
|
||||
</span>
|
||||
</a>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{/* Navigation */}}
|
||||
<ul
|
||||
|
||||
Reference in New Issue
Block a user