lofi / src /lib /components /DynamicBackgroundWebGL.svelte
veltrixcode's picture
Upload 84 files
3c56493 verified
Raw
History Blame Contribute Delete
17.6 kB
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { playerStore } from '$lib/stores/player';
import { effectivePerformanceLevel } from '$lib/stores/performance';
import { losslessAPI } from '$lib/api';
import {
extractPaletteFromImage,
getMostVibrantColor,
type Color
} from '$lib/utils/colorExtraction';
import {
vertexShaderSource,
updateStateShaderSource,
colorRenderShaderSource,
blurFragmentShaderSource,
createShader,
createProgram,
setupQuad,
createTexture,
createFramebuffer
} from '$lib/utils/webglShaders';
// Constants
const DISPLAY_CANVAS_SIZE = 512;
const MASTER_PALETTE_SIZE = 40;
const DISPLAY_GRID_WIDTH = 8;
const DISPLAY_GRID_HEIGHT = 5;
const STRETCHED_GRID_WIDTH = 32;
const STRETCHED_GRID_HEIGHT = 18;
const BLUR_DOWNSAMPLE_FACTOR = 26;
const SONG_PALETTE_TRANSITION_SPEED = 0.015;
const SCROLL_SPEED = 0.008;
// Canvas and WebGL context
let canvasElement: HTMLCanvasElement;
let gl: WebGLRenderingContext | null = null;
// Shader programs
let updateStateProgram: WebGLProgram | null = null;
let colorRenderProgram: WebGLProgram | null = null;
let blurProgram: WebGLProgram | null = null;
// Textures and framebuffers
let paletteTexture: WebGLTexture | null = null;
let cellStateTexture: WebGLTexture | null = null;
let cellStateTexture2: WebGLTexture | null = null;
let colorRenderTexture: WebGLTexture | null = null;
let blurTexture1: WebGLTexture | null = null;
let blurTexture2: WebGLTexture | null = null;
let stateFramebuffer1: WebGLFramebuffer | null = null;
let stateFramebuffer2: WebGLFramebuffer | null = null;
let colorRenderFramebuffer: WebGLFramebuffer | null = null;
let blurFramebuffer1: WebGLFramebuffer | null = null;
let blurFramebuffer2: WebGLFramebuffer | null = null;
// Animation state
let previousPalette: Color[] = [];
let targetPalette: Color[] = [];
let songPaletteTransitionProgress = 1.0;
let scrollOffset = 0.0;
let lastFrameTime = performance.now();
let animationFrameId: number | null = null;
let currentStateTexture = 0; // 0 or 1 for ping-pong
// Performance flags
let isLightweight = false;
let isVisible = true;
// Track current song
let currentTrackId: number | string | null = null;
onMount(() => {
initializeWebGL();
setupIntersectionObserver();
// Subscribe to performance level changes
const unsubscribePerf = effectivePerformanceLevel.subscribe((level) => {
isLightweight = level === 'low';
});
// Subscribe to player changes
const unsubscribePlayer = playerStore.subscribe(async (state) => {
if (state.currentTrack && state.currentTrack.id !== currentTrackId) {
currentTrackId = state.currentTrack.id;
let coverUrl = '';
if ('thumbnailUrl' in state.currentTrack && state.currentTrack.thumbnailUrl) {
coverUrl = state.currentTrack.thumbnailUrl;
} else if ('album' in state.currentTrack && state.currentTrack.album?.cover) {
coverUrl = state.currentTrack.album.cover;
}
if (coverUrl) {
await updateFromTrack(coverUrl);
}
}
});
return () => {
unsubscribePerf();
unsubscribePlayer();
};
});
onDestroy(() => {
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
}
cleanupWebGL();
});
function initializeWebGL() {
if (!canvasElement) return;
// Set canvas size
canvasElement.width = DISPLAY_CANVAS_SIZE;
canvasElement.height = DISPLAY_CANVAS_SIZE;
// Get WebGL context
gl = canvasElement.getContext('webgl', {
alpha: false,
antialias: false,
depth: false,
premultipliedAlpha: false,
preserveDrawingBuffer: false
});
if (!gl) {
console.error('WebGL not supported');
return;
}
// Handle context loss
canvasElement.addEventListener('webglcontextlost', handleContextLost, false);
canvasElement.addEventListener('webglcontextrestored', handleContextRestored, false);
setupShaders();
setupTextures();
setupFramebuffers();
initializeCellStates();
startAnimation();
}
function setupShaders() {
if (!gl) return;
// Create vertex shader
const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
if (!vertexShader) return;
// Create update state program
const updateStateFragShader = createShader(gl, gl.FRAGMENT_SHADER, updateStateShaderSource);
if (updateStateFragShader) {
updateStateProgram = createProgram(gl, vertexShader, updateStateFragShader);
}
// Create color render program
const colorRenderFragShader = createShader(gl, gl.FRAGMENT_SHADER, colorRenderShaderSource);
if (colorRenderFragShader) {
colorRenderProgram = createProgram(gl, vertexShader, colorRenderFragShader);
}
// Create blur program
const blurFragShader = createShader(gl, gl.FRAGMENT_SHADER, blurFragmentShaderSource);
if (blurFragShader) {
blurProgram = createProgram(gl, vertexShader, blurFragShader);
}
setupQuad(gl);
}
function setupTextures() {
if (!gl) return;
const blurWidth = Math.round(DISPLAY_CANVAS_SIZE / BLUR_DOWNSAMPLE_FACTOR);
const blurHeight = Math.round(DISPLAY_CANVAS_SIZE / BLUR_DOWNSAMPLE_FACTOR);
// Palette texture (8x10 - double-buffered palette)
paletteTexture = createTexture(gl, DISPLAY_GRID_WIDTH, DISPLAY_GRID_HEIGHT * 2);
// Cell state textures (8x5 - ping-pong for state updates)
cellStateTexture = createTexture(gl, DISPLAY_GRID_WIDTH, DISPLAY_GRID_HEIGHT);
cellStateTexture2 = createTexture(gl, DISPLAY_GRID_WIDTH, DISPLAY_GRID_HEIGHT);
// Color render texture (32x18)
colorRenderTexture = createTexture(gl, STRETCHED_GRID_WIDTH, STRETCHED_GRID_HEIGHT);
// Blur textures
blurTexture1 = createTexture(gl, blurWidth, blurHeight);
blurTexture2 = createTexture(gl, blurWidth, blurHeight);
}
function setupFramebuffers() {
if (!gl || !cellStateTexture || !cellStateTexture2 || !colorRenderTexture || !blurTexture1 || !blurTexture2) return;
stateFramebuffer1 = createFramebuffer(gl, cellStateTexture);
stateFramebuffer2 = createFramebuffer(gl, cellStateTexture2);
colorRenderFramebuffer = createFramebuffer(gl, colorRenderTexture);
blurFramebuffer1 = createFramebuffer(gl, blurTexture1);
blurFramebuffer2 = createFramebuffer(gl, blurTexture2);
}
function initializeCellStates() {
if (!gl || !cellStateTexture) return;
// Initialize with random states
const stateData = new Uint8Array(DISPLAY_GRID_WIDTH * DISPLAY_GRID_HEIGHT * 4);
for (let i = 0; i < MASTER_PALETTE_SIZE; i++) {
const idx = i * 4;
const sourceIdx = Math.floor(Math.random() * MASTER_PALETTE_SIZE);
const targetIdx = Math.floor(Math.random() * MASTER_PALETTE_SIZE);
const progress = Math.random();
const speed = (Math.random() * 0.5 + 0.5) * 0.48;
stateData[idx] = Math.round((sourceIdx / 39) * 255);
stateData[idx + 1] = Math.round((targetIdx / 39) * 255);
stateData[idx + 2] = Math.round(progress * 255);
stateData[idx + 3] = Math.round((speed / 10.0) * 255);
}
gl.bindTexture(gl.TEXTURE_2D, cellStateTexture);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
DISPLAY_GRID_WIDTH,
DISPLAY_GRID_HEIGHT,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
stateData
);
}
async function updateFromTrack(coverUrl: string) {
try {
const fullCoverUrl = coverUrl.startsWith('http') ? coverUrl : losslessAPI.getCoverUrl(coverUrl, '640');
const palette = await extractPaletteFromImage(
fullCoverUrl,
DISPLAY_GRID_WIDTH,
DISPLAY_GRID_HEIGHT,
STRETCHED_GRID_WIDTH,
STRETCHED_GRID_HEIGHT
);
// Shift current target to previous
previousPalette = targetPalette.length > 0 ? targetPalette : palette;
targetPalette = palette;
// Update palette texture
updatePaletteTexture();
// Reset transition
songPaletteTransitionProgress = 0.0;
// Get vibrant color for lyrics (could be exposed via a store)
const vibrantColor = getMostVibrantColor(palette);
document.documentElement.style.setProperty(
'--dynamic-bg-vibrant',
`rgb(${vibrantColor.r}, ${vibrantColor.g}, ${vibrantColor.b})`
);
} catch (error) {
console.error('Failed to update background from track:', error);
}
}
function updatePaletteTexture() {
if (!gl || !paletteTexture) return;
const textureData = new Uint8Array(DISPLAY_GRID_WIDTH * DISPLAY_GRID_HEIGHT * 2 * 4);
// Write previous palette to rows 0-4
for (let i = 0; i < MASTER_PALETTE_SIZE; i++) {
const color = previousPalette[i] || { r: 0, g: 0, b: 0, a: 255 };
const idx = i * 4;
textureData[idx] = color.r;
textureData[idx + 1] = color.g;
textureData[idx + 2] = color.b;
textureData[idx + 3] = color.a;
}
// Write target palette to rows 5-9
for (let i = 0; i < MASTER_PALETTE_SIZE; i++) {
const color = targetPalette[i] || { r: 0, g: 0, b: 0, a: 255 };
const idx = (MASTER_PALETTE_SIZE + i) * 4;
textureData[idx] = color.r;
textureData[idx + 1] = color.g;
textureData[idx + 2] = color.b;
textureData[idx + 3] = color.a;
}
gl.bindTexture(gl.TEXTURE_2D, paletteTexture);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
DISPLAY_GRID_WIDTH,
DISPLAY_GRID_HEIGHT * 2,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
textureData
);
}
function startAnimation() {
if (animationFrameId !== null) return;
lastFrameTime = performance.now();
animationFrameId = requestAnimationFrame(animate);
}
function stopAnimation() {
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
}
function animate(currentTime: number) {
if (!gl || !isVisible) {
animationFrameId = requestAnimationFrame(animate);
return;
}
const deltaTime = Math.min((currentTime - lastFrameTime) / 1000, 0.1); // Cap at 100ms
lastFrameTime = currentTime;
// Update animation state
if (songPaletteTransitionProgress < 1.0) {
songPaletteTransitionProgress = Math.min(1.0, songPaletteTransitionProgress + SONG_PALETTE_TRANSITION_SPEED);
}
scrollOffset += SCROLL_SPEED * deltaTime;
if (scrollOffset >= 1.0) scrollOffset -= 1.0;
// Render pipeline
renderPipeline(deltaTime, currentTime);
animationFrameId = requestAnimationFrame(animate);
}
function renderPipeline(deltaTime: number, currentTime: number) {
if (!gl) return;
// Pass 1: Update cell states (skip in lightweight mode unless transitioning)
if (!isLightweight || songPaletteTransitionProgress < 1.0) {
updateCellStates(deltaTime, currentTime);
}
// Pass 2: Render colors with current states
renderColors();
// Pass 3: Horizontal blur
renderHorizontalBlur();
// Pass 4: Vertical blur and display
renderVerticalBlur();
}
function updateCellStates(deltaTime: number, currentTime: number) {
if (!gl || !updateStateProgram || !stateFramebuffer1 || !stateFramebuffer2) return;
gl.useProgram(updateStateProgram);
// Bind source state texture
const sourceTexture = currentStateTexture === 0 ? cellStateTexture : cellStateTexture2;
const targetFramebuffer = currentStateTexture === 0 ? stateFramebuffer2 : stateFramebuffer1;
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, sourceTexture);
gl.uniform1i(gl.getUniformLocation(updateStateProgram, 'u_currentStateTexture'), 0);
gl.uniform1f(gl.getUniformLocation(updateStateProgram, 'u_deltaTime'), deltaTime);
gl.uniform1f(gl.getUniformLocation(updateStateProgram, 'u_time'), currentTime);
// Set up attributes
setupAttributes(updateStateProgram);
// Render to target framebuffer
gl.bindFramebuffer(gl.FRAMEBUFFER, targetFramebuffer);
gl.viewport(0, 0, DISPLAY_GRID_WIDTH, DISPLAY_GRID_HEIGHT);
gl.drawArrays(gl.TRIANGLES, 0, 6);
// Swap state textures
currentStateTexture = 1 - currentStateTexture;
}
function renderColors() {
if (!gl || !colorRenderProgram || !colorRenderFramebuffer) return;
gl.useProgram(colorRenderProgram);
// Bind palette texture
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, paletteTexture);
gl.uniform1i(gl.getUniformLocation(colorRenderProgram, 'u_paletteTexture'), 0);
// Bind cell state texture
const currentTexture = currentStateTexture === 0 ? cellStateTexture : cellStateTexture2;
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, currentTexture);
gl.uniform1i(gl.getUniformLocation(colorRenderProgram, 'u_cellStateTexture'), 1);
// Set uniforms
gl.uniform1f(
gl.getUniformLocation(colorRenderProgram, 'u_songPaletteTransitionProgress'),
songPaletteTransitionProgress
);
gl.uniform1f(gl.getUniformLocation(colorRenderProgram, 'u_scrollOffset'), scrollOffset);
// Set up attributes
setupAttributes(colorRenderProgram);
// Render to color framebuffer
gl.bindFramebuffer(gl.FRAMEBUFFER, colorRenderFramebuffer);
gl.viewport(0, 0, STRETCHED_GRID_WIDTH, STRETCHED_GRID_HEIGHT);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
function renderHorizontalBlur() {
if (!gl || !blurProgram || !blurFramebuffer1) return;
gl.useProgram(blurProgram);
// Bind color render texture
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, colorRenderTexture);
gl.uniform1i(gl.getUniformLocation(blurProgram, 'u_image'), 0);
const blurWidth = Math.round(DISPLAY_CANVAS_SIZE / BLUR_DOWNSAMPLE_FACTOR);
const blurHeight = Math.round(DISPLAY_CANVAS_SIZE / BLUR_DOWNSAMPLE_FACTOR);
gl.uniform2f(gl.getUniformLocation(blurProgram, 'u_resolution'), blurWidth, blurHeight);
gl.uniform2f(gl.getUniformLocation(blurProgram, 'u_direction'), 1.0, 0.0); // Horizontal
// Set up attributes
setupAttributes(blurProgram);
// Render to blur framebuffer
gl.bindFramebuffer(gl.FRAMEBUFFER, blurFramebuffer1);
gl.viewport(0, 0, blurWidth, blurHeight);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
function renderVerticalBlur() {
if (!gl || !blurProgram) return;
gl.useProgram(blurProgram);
// Bind horizontal blur result
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, blurTexture1);
gl.uniform1i(gl.getUniformLocation(blurProgram, 'u_image'), 0);
const blurWidth = Math.round(DISPLAY_CANVAS_SIZE / BLUR_DOWNSAMPLE_FACTOR);
const blurHeight = Math.round(DISPLAY_CANVAS_SIZE / BLUR_DOWNSAMPLE_FACTOR);
gl.uniform2f(gl.getUniformLocation(blurProgram, 'u_resolution'), blurWidth, blurHeight);
gl.uniform2f(gl.getUniformLocation(blurProgram, 'u_direction'), 0.0, 1.0); // Vertical
// Set up attributes
setupAttributes(blurProgram);
// Render to canvas
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, DISPLAY_CANVAS_SIZE, DISPLAY_CANVAS_SIZE);
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
function setupAttributes(program: WebGLProgram) {
if (!gl) return;
const positionLocation = gl.getAttribLocation(program, 'a_position');
const texCoordLocation = gl.getAttribLocation(program, 'a_texCoord');
// Position attribute
const positions = new Float32Array([
-1.0, -1.0, 1.0, -1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0, 1.0, 1.0
]);
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
gl.enableVertexAttribArray(positionLocation);
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
// TexCoord attribute
const texCoords = new Float32Array([0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0]);
const texCoordBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
gl.bufferData(gl.ARRAY_BUFFER, texCoords, gl.STATIC_DRAW);
gl.enableVertexAttribArray(texCoordLocation);
gl.vertexAttribPointer(texCoordLocation, 2, gl.FLOAT, false, 0, 0);
}
function setupIntersectionObserver() {
if (!canvasElement || typeof IntersectionObserver === 'undefined') return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
isVisible = entry.isIntersecting;
if (entry.isIntersecting) {
startAnimation();
} else {
stopAnimation();
}
}
},
{ threshold: 0.01 }
);
observer.observe(canvasElement);
}
function handleContextLost(event: Event) {
event.preventDefault();
stopAnimation();
console.warn('WebGL context lost');
}
function handleContextRestored() {
console.log('WebGL context restored');
initializeWebGL();
}
function cleanupWebGL() {
if (!gl) return;
// Delete textures
if (paletteTexture) gl.deleteTexture(paletteTexture);
if (cellStateTexture) gl.deleteTexture(cellStateTexture);
if (cellStateTexture2) gl.deleteTexture(cellStateTexture2);
if (colorRenderTexture) gl.deleteTexture(colorRenderTexture);
if (blurTexture1) gl.deleteTexture(blurTexture1);
if (blurTexture2) gl.deleteTexture(blurTexture2);
// Delete framebuffers
if (stateFramebuffer1) gl.deleteFramebuffer(stateFramebuffer1);
if (stateFramebuffer2) gl.deleteFramebuffer(stateFramebuffer2);
if (colorRenderFramebuffer) gl.deleteFramebuffer(colorRenderFramebuffer);
if (blurFramebuffer1) gl.deleteFramebuffer(blurFramebuffer1);
if (blurFramebuffer2) gl.deleteFramebuffer(blurFramebuffer2);
// Delete programs
if (updateStateProgram) gl.deleteProgram(updateStateProgram);
if (colorRenderProgram) gl.deleteProgram(colorRenderProgram);
if (blurProgram) gl.deleteProgram(blurProgram);
}
</script>
<div class="dynamic-background-container">
<canvas bind:this={canvasElement} class="dynamic-background-canvas"></canvas>
</div>
<style>
.dynamic-background-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
overflow: hidden;
pointer-events: none;
}
.dynamic-background-canvas {
width: 100%;
height: 100%;
object-fit: cover;
filter: blur(0px);
}
</style>