Switch to Canvas 2D wave visualizer for lower CPU usage
- Replace WebGL2 instanced bars with simple Canvas 2D waves - Mirror wave from center (top + bottom) - Quadratic bezier curves for smooth lines - Gradient coloring: cyan -> blue -> purple - Black #000000 background - Much lighter than 128 WebGL instances
This commit is contained in:
1 parent
283d17b5b8
commit
55c3e80bbc
1 file changed
+90
-216
@@ -13,180 +13,27 @@
|
||||
let { enabled = true, artwork = '', trackTitle = '', artist = '' }: Props = $props();
|
||||
|
||||
let canvasRef: HTMLCanvasElement | null = $state(null);
|
||||
let gl: WebGL2RenderingContext | null = null;
|
||||
let program: WebGLProgram | null = null;
|
||||
let frequencyTexture: WebGLTexture | null = null;
|
||||
let vao: WebGLVertexArrayObject | null = null;
|
||||
let ctx: CanvasRenderingContext2D | null = null;
|
||||
let animationFrame: number | null = null;
|
||||
let unlisten: UnlistenFn | null = null;
|
||||
let isInitialized = false;
|
||||
|
||||
const NUM_BARS = 64;
|
||||
const frequencyData = new Float32Array(NUM_BARS);
|
||||
const smoothedData = new Float32Array(NUM_BARS);
|
||||
|
||||
// Vertex shader - MIRROR mode: bars extend from center up and down
|
||||
const vertexSource = `#version 300 es
|
||||
precision highp float;
|
||||
// Smoothing for visual continuity
|
||||
const SMOOTHING = 0.7;
|
||||
|
||||
in vec2 a_position;
|
||||
in float a_mirror; // 0 = top half, 1 = bottom half (mirrored)
|
||||
|
||||
uniform sampler2D u_frequencies;
|
||||
uniform vec2 u_resolution;
|
||||
uniform float u_barWidth;
|
||||
uniform float u_gap;
|
||||
uniform float u_maxHeight;
|
||||
uniform float u_centerY;
|
||||
|
||||
out float v_height;
|
||||
out float v_barIndex;
|
||||
out float v_verticalPos;
|
||||
|
||||
void main() {
|
||||
int barIndex = gl_InstanceID / 2; // Each bar has 2 instances (top + bottom)
|
||||
int isBottom = gl_InstanceID % 2;
|
||||
|
||||
float freq = texelFetch(u_frequencies, ivec2(barIndex, 0), 0).r;
|
||||
|
||||
float totalWidth = u_barWidth + u_gap;
|
||||
float totalBarsWidth = float(${NUM_BARS}) * totalWidth - u_gap;
|
||||
float offsetX = (u_resolution.x - totalBarsWidth) / 2.0;
|
||||
|
||||
float x = offsetX + float(barIndex) * totalWidth + a_position.x * u_barWidth;
|
||||
|
||||
// Mirror: top bars go up from center, bottom bars go down
|
||||
float barHeight = freq * u_maxHeight;
|
||||
float y;
|
||||
if (isBottom == 0) {
|
||||
// Top half: y goes from centerY to centerY + height
|
||||
y = u_centerY + a_position.y * barHeight;
|
||||
} else {
|
||||
// Bottom half: y goes from centerY to centerY - height (mirrored)
|
||||
y = u_centerY - a_position.y * barHeight;
|
||||
}
|
||||
|
||||
vec2 clipSpace = (vec2(x, y) / u_resolution) * 2.0 - 1.0;
|
||||
gl_Position = vec4(clipSpace * vec2(1, 1), 0, 1);
|
||||
|
||||
v_height = freq;
|
||||
v_barIndex = float(barIndex) / float(${NUM_BARS});
|
||||
v_verticalPos = a_position.y;
|
||||
}
|
||||
`;
|
||||
|
||||
// Fragment shader with enhanced visuals
|
||||
const fragmentSource = `#version 300 es
|
||||
precision highp float;
|
||||
|
||||
in float v_height;
|
||||
in float v_barIndex;
|
||||
in float v_verticalPos;
|
||||
|
||||
out vec4 fragColor;
|
||||
|
||||
uniform vec3 u_colorLow;
|
||||
uniform vec3 u_colorMid;
|
||||
uniform vec3 u_colorHigh;
|
||||
|
||||
void main() {
|
||||
// Three-color gradient based on vertical position
|
||||
vec3 color;
|
||||
if (v_verticalPos < 0.5) {
|
||||
color = mix(u_colorLow, u_colorMid, v_verticalPos * 2.0);
|
||||
} else {
|
||||
color = mix(u_colorMid, u_colorHigh, (v_verticalPos - 0.5) * 2.0);
|
||||
}
|
||||
|
||||
// Intensity boost based on frequency height
|
||||
color *= 0.7 + 0.5 * v_height;
|
||||
|
||||
// Glow effect for peaks
|
||||
float glow = smoothstep(0.5, 1.0, v_height) * 0.3;
|
||||
color += glow;
|
||||
|
||||
// Slight transparency fade at edges
|
||||
float alpha = 0.9 - 0.1 * v_verticalPos;
|
||||
|
||||
fragColor = vec4(color, alpha);
|
||||
}
|
||||
`;
|
||||
|
||||
function createShader(glCtx: WebGL2RenderingContext, type: number, source: string): WebGLShader | null {
|
||||
const shader = glCtx.createShader(type);
|
||||
if (!shader) return null;
|
||||
|
||||
glCtx.shaderSource(shader, source);
|
||||
glCtx.compileShader(shader);
|
||||
|
||||
if (!glCtx.getShaderParameter(shader, glCtx.COMPILE_STATUS)) {
|
||||
console.error('Shader compile error:', glCtx.getShaderInfoLog(shader));
|
||||
glCtx.deleteShader(shader);
|
||||
return null;
|
||||
}
|
||||
|
||||
return shader;
|
||||
}
|
||||
|
||||
function createProgram(glCtx: WebGL2RenderingContext, vs: WebGLShader, fs: WebGLShader): WebGLProgram | null {
|
||||
const prog = glCtx.createProgram();
|
||||
if (!prog) return null;
|
||||
|
||||
glCtx.attachShader(prog, vs);
|
||||
glCtx.attachShader(prog, fs);
|
||||
glCtx.linkProgram(prog);
|
||||
|
||||
if (!glCtx.getProgramParameter(prog, glCtx.LINK_STATUS)) {
|
||||
console.error('Program link error:', glCtx.getProgramInfoLog(prog));
|
||||
glCtx.deleteProgram(prog);
|
||||
return null;
|
||||
}
|
||||
|
||||
return prog;
|
||||
}
|
||||
|
||||
async function initWebGL() {
|
||||
async function init() {
|
||||
if (!canvasRef || isInitialized) return;
|
||||
|
||||
gl = canvasRef.getContext('webgl2', { alpha: true, premultipliedAlpha: false });
|
||||
if (!gl) {
|
||||
console.warn('WebGL2 not available for visualizer');
|
||||
ctx = canvasRef.getContext('2d');
|
||||
if (!ctx) {
|
||||
console.warn('Canvas 2D not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const vs = createShader(gl, gl.VERTEX_SHADER, vertexSource);
|
||||
const fs = createShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
|
||||
if (!vs || !fs) return;
|
||||
|
||||
program = createProgram(gl, vs, fs);
|
||||
if (!program) return;
|
||||
|
||||
// Create VAO with quad vertices
|
||||
vao = gl.createVertexArray();
|
||||
gl.bindVertexArray(vao);
|
||||
|
||||
// Quad: bottom-left origin, extends up and right
|
||||
const positions = new Float32Array([
|
||||
0, 0, 1, 0, 0, 1,
|
||||
0, 1, 1, 0, 1, 1,
|
||||
]);
|
||||
|
||||
const posBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, posBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
|
||||
|
||||
const posLoc = gl.getAttribLocation(program, 'a_position');
|
||||
gl.enableVertexAttribArray(posLoc);
|
||||
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
// Create frequency texture
|
||||
frequencyTexture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, frequencyTexture);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.R32F, NUM_BARS, 1, 0, gl.RED, gl.FLOAT, frequencyData);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
|
||||
isInitialized = true;
|
||||
|
||||
// Enable backend
|
||||
@@ -201,12 +48,14 @@
|
||||
unlisten = await listen<number[]>('viz:data', (event) => {
|
||||
const payload = event.payload;
|
||||
if (Array.isArray(payload)) {
|
||||
// Binary data came as array of numbers (bytes)
|
||||
const bytes = new Uint8Array(payload);
|
||||
const floats = new Float32Array(bytes.buffer);
|
||||
if (floats.length === NUM_BARS) {
|
||||
frequencyData.set(floats);
|
||||
updateFrequencyTexture();
|
||||
// Apply smoothing
|
||||
for (let i = 0; i < NUM_BARS; i++) {
|
||||
smoothedData[i] = smoothedData[i] * SMOOTHING + floats[i] * (1 - SMOOTHING);
|
||||
}
|
||||
frequencyData.set(smoothedData);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -214,56 +63,87 @@
|
||||
render();
|
||||
}
|
||||
|
||||
function updateFrequencyTexture() {
|
||||
if (!gl || !frequencyTexture) return;
|
||||
gl.bindTexture(gl.TEXTURE_2D, frequencyTexture);
|
||||
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, NUM_BARS, 1, gl.RED, gl.FLOAT, frequencyData);
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!gl || !program || !frequencyTexture || !canvasRef || !vao) return;
|
||||
if (!ctx || !canvasRef) return;
|
||||
|
||||
const rect = canvasRef.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvasRef.width = rect.width * dpr;
|
||||
canvasRef.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
gl.viewport(0, 0, canvasRef.width, canvasRef.height);
|
||||
gl.clearColor(0, 0, 0, 0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
const width = rect.width;
|
||||
const height = rect.height;
|
||||
const centerY = height / 2;
|
||||
const maxAmplitude = height * 0.35;
|
||||
|
||||
gl.useProgram(program);
|
||||
gl.bindVertexArray(vao);
|
||||
// Clear with black
|
||||
ctx.fillStyle = '#000000';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// Calculate dimensions
|
||||
const barWidth = (rect.width / NUM_BARS) * 0.65;
|
||||
const gap = (rect.width / NUM_BARS) * 0.35;
|
||||
const maxHeight = rect.height * 0.35; // Each half gets 35% of height
|
||||
const centerY = rect.height * 0.5;
|
||||
// Draw wave - top half
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, centerY);
|
||||
|
||||
// Set uniforms
|
||||
gl.uniform2f(gl.getUniformLocation(program, 'u_resolution'), rect.width, rect.height);
|
||||
gl.uniform1f(gl.getUniformLocation(program, 'u_barWidth'), barWidth);
|
||||
gl.uniform1f(gl.getUniformLocation(program, 'u_gap'), gap);
|
||||
gl.uniform1f(gl.getUniformLocation(program, 'u_maxHeight'), maxHeight);
|
||||
gl.uniform1f(gl.getUniformLocation(program, 'u_centerY'), centerY);
|
||||
for (let i = 0; i < NUM_BARS; i++) {
|
||||
const x = (i / (NUM_BARS - 1)) * width;
|
||||
const amplitude = frequencyData[i] * maxAmplitude;
|
||||
const y = centerY - amplitude;
|
||||
|
||||
// Colors: teal -> purple -> pink
|
||||
gl.uniform3f(gl.getUniformLocation(program, 'u_colorLow'), 0.0, 0.7, 0.7);
|
||||
gl.uniform3f(gl.getUniformLocation(program, 'u_colorMid'), 0.5, 0.2, 0.8);
|
||||
gl.uniform3f(gl.getUniformLocation(program, 'u_colorHigh'), 0.9, 0.3, 0.6);
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
// Smooth curve using quadratic bezier
|
||||
const prevX = ((i - 1) / (NUM_BARS - 1)) * width;
|
||||
const cpX = (prevX + x) / 2;
|
||||
ctx.quadraticCurveTo(prevX, centerY - frequencyData[i - 1] * maxAmplitude, cpX, (centerY - frequencyData[i - 1] * maxAmplitude + y) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
// Bind frequency texture
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, frequencyTexture);
|
||||
gl.uniform1i(gl.getUniformLocation(program, 'u_frequencies'), 0);
|
||||
// Create gradient for top wave
|
||||
const gradientTop = ctx.createLinearGradient(0, centerY - maxAmplitude, 0, centerY);
|
||||
gradientTop.addColorStop(0, 'rgba(0, 255, 200, 0.9)');
|
||||
gradientTop.addColorStop(0.5, 'rgba(100, 100, 255, 0.7)');
|
||||
gradientTop.addColorStop(1, 'rgba(150, 50, 200, 0.3)');
|
||||
|
||||
// Enable blending
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
ctx.strokeStyle = gradientTop;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.stroke();
|
||||
|
||||
// Draw: 2 instances per bar (top + bottom mirror)
|
||||
gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, NUM_BARS * 2);
|
||||
// Draw mirrored wave - bottom half
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < NUM_BARS; i++) {
|
||||
const x = (i / (NUM_BARS - 1)) * width;
|
||||
const amplitude = frequencyData[i] * maxAmplitude;
|
||||
const y = centerY + amplitude;
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
const prevX = ((i - 1) / (NUM_BARS - 1)) * width;
|
||||
const cpX = (prevX + x) / 2;
|
||||
ctx.quadraticCurveTo(prevX, centerY + frequencyData[i - 1] * maxAmplitude, cpX, (centerY + frequencyData[i - 1] * maxAmplitude + y) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
// Gradient for bottom wave (mirrored colors)
|
||||
const gradientBottom = ctx.createLinearGradient(0, centerY, 0, centerY + maxAmplitude);
|
||||
gradientBottom.addColorStop(0, 'rgba(150, 50, 200, 0.3)');
|
||||
gradientBottom.addColorStop(0.5, 'rgba(100, 100, 255, 0.7)');
|
||||
gradientBottom.addColorStop(1, 'rgba(0, 255, 200, 0.9)');
|
||||
|
||||
ctx.strokeStyle = gradientBottom;
|
||||
ctx.stroke();
|
||||
|
||||
// Draw center line (subtle)
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, centerY);
|
||||
ctx.lineTo(width, centerY);
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
|
||||
animationFrame = requestAnimationFrame(render);
|
||||
}
|
||||
@@ -286,25 +166,19 @@
|
||||
console.error('[Visualizer] Failed to disable backend:', e);
|
||||
}
|
||||
|
||||
if (gl) {
|
||||
if (frequencyTexture) gl.deleteTexture(frequencyTexture);
|
||||
if (program) gl.deleteProgram(program);
|
||||
if (vao) gl.deleteVertexArray(vao);
|
||||
}
|
||||
|
||||
isInitialized = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (enabled) {
|
||||
initWebGL();
|
||||
init();
|
||||
}
|
||||
return cleanup;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (enabled && !isInitialized) {
|
||||
initWebGL();
|
||||
init();
|
||||
} else if (!enabled && isInitialized) {
|
||||
cleanup();
|
||||
}
|
||||
@@ -314,7 +188,6 @@
|
||||
<div class="visualizer-panel" class:visible={enabled}>
|
||||
<canvas bind:this={canvasRef} class="visualizer-canvas"></canvas>
|
||||
|
||||
<!-- Track info overlay -->
|
||||
<div class="track-info">
|
||||
{#if artwork}
|
||||
<img src={artwork} alt={trackTitle} class="artwork" />
|
||||
@@ -337,6 +210,7 @@
|
||||
opacity: 0;
|
||||
transition: opacity 300ms ease;
|
||||
z-index: 5;
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
.visualizer-panel.visible {
|
||||
@@ -361,10 +235,10 @@
|
||||
}
|
||||
|
||||
.artwork {
|
||||
width: min(200px, 25vw);
|
||||
height: min(200px, 25vw);
|
||||
width: min(180px, 22vw);
|
||||
height: min(180px, 22vw);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
@@ -375,17 +249,17 @@
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: clamp(18px, 2.5vw, 28px);
|
||||
font-size: clamp(16px, 2.2vw, 24px);
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.artist {
|
||||
font-size: clamp(14px, 1.8vw, 18px);
|
||||
font-size: clamp(12px, 1.5vw, 16px);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user