feat(immersive): integrate WebGL2 renderer into ImmersiveBackground

- ImmersiveBackground now checks WebGL2 availability on mount
- Dynamically imports ImmersiveAmbientCanvas when WebGL2 available
- Falls back to CSS blur when WebGL2 unavailable
- New props: enableAmbient (default true), ambientIntensity

Automatic renderer selection:
1. Check BUILD_IMMERSIVE_ENABLED (compile-time)
2. Check isRuntimeEnabled() (user preference)
3. Check isWebGL2Available() (browser capability)
4. If all pass: use WebGL2 with ambient motion
5. Otherwise: use CSS fallback (original behavior)
This commit is contained in:
vicrodh committed 2026-02-01 10:13:50 -06:00
1 parent c94857fc87
commit 34710e92e1
1 file changed
+100 -27
@@ -1,17 +1,70 @@
<script lang="ts">
/**
* Immersive Background
*
* Renders the blurred album artwork background for the immersive player.
* Uses WebGL2 when available (with ambient motion), falls back to CSS blur.
*/
import { onMount } from 'svelte';
import {
BUILD_IMMERSIVE_ENABLED,
isWebGL2Available,
isRuntimeEnabled,
} from '$lib/immersive';
interface Props {
artwork: string;
/** Enable ambient motion effect (WebGL2 only) */
enableAmbient?: boolean;
/** Ambient motion intensity 0-1 (WebGL2 only) */
ambientIntensity?: number;
}
let { artwork }: Props = $props();
let { artwork, enableAmbient = true, ambientIntensity }: Props = $props();
// Determine which renderer to use
let useWebGL = $state(false);
let useFallback = $state(false);
let WebGLCanvas: typeof import('$lib/immersive/ImmersiveAmbientCanvas.svelte').default | null = $state(null);
// Fallback state (CSS-based blur)
let canvasRef: HTMLCanvasElement | undefined = $state();
let isLoading = $state(true);
let currentArtwork = $state('');
// Generate a small blurred version using Canvas
// Key insight: blur(30px) on a 64x64 image is MUCH cheaper than blur(120px) on full image
// The small source + small blur = smooth result with minimal CPU
// Check capabilities and load WebGL component if available
onMount(async () => {
if (BUILD_IMMERSIVE_ENABLED && isRuntimeEnabled() && isWebGL2Available()) {
try {
// Dynamic import - only loads if WebGL2 is available
const module = await import('$lib/immersive/ImmersiveAmbientCanvas.svelte');
WebGLCanvas = module.default;
useWebGL = true;
console.log('[ImmersiveBackground] Using WebGL2 renderer');
} catch (e) {
console.warn('[ImmersiveBackground] Failed to load WebGL canvas:', e);
useFallback = true;
}
} else {
useFallback = true;
console.log('[ImmersiveBackground] Using CSS fallback');
}
});
/**
* Handle WebGL fallback (if WebGL init fails)
*/
function handleWebGLFallback(): void {
console.warn('[ImmersiveBackground] WebGL failed, switching to CSS fallback');
useWebGL = false;
useFallback = true;
}
// =====================================================
// CSS Fallback Implementation (original code)
// =====================================================
async function generateBlurredBackground(imageUrl: string): Promise<void> {
if (!canvasRef || !imageUrl) return;
@@ -22,15 +75,12 @@
img.crossOrigin = 'anonymous';
img.onload = () => {
// Use 64x64 - large enough to avoid blocky pixels, small enough to be efficient
const size = 64;
canvasRef!.width = size;
canvasRef!.height = size;
// Draw image scaled down
ctx.drawImage(img, 0, 0, size, size);
// Apply color adjustments
const imageData = ctx.getImageData(0, 0, size, size);
const data = imageData.data;
@@ -40,13 +90,11 @@
const b = data[i + 2];
const avg = (r + g + b) / 3;
// Saturation boost (1.3x)
const satFactor = 1.3;
let newR = avg + (r - avg) * satFactor;
let newG = avg + (g - avg) * satFactor;
let newB = avg + (b - avg) * satFactor;
// Brightness reduction (0.55x) - slightly darker for better text contrast
data[i] = Math.min(255, Math.max(0, newR * 0.55));
data[i + 1] = Math.min(255, Math.max(0, newG * 0.55));
data[i + 2] = Math.min(255, Math.max(0, newB * 0.55));
@@ -63,22 +111,38 @@
img.src = imageUrl;
}
// Track artwork changes
// Track artwork changes for fallback renderer
$effect(() => {
if (!artwork || artwork === currentArtwork) return;
currentArtwork = artwork;
isLoading = true;
generateBlurredBackground(artwork);
if (useFallback && artwork && artwork !== currentArtwork) {
currentArtwork = artwork;
isLoading = true;
generateBlurredBackground(artwork);
}
});
</script>
<div class="immersive-background" class:loading={isLoading}>
<!-- Canvas-generated tiny image, scaled up via CSS -->
<canvas
bind:this={canvasRef}
class="background-canvas"
aria-hidden="true"
></canvas>
<div class="immersive-background">
{#if useWebGL && WebGLCanvas}
<!-- WebGL2 Renderer with ambient motion -->
<WebGLCanvas
artworkUrl={artwork}
enableMotion={enableAmbient}
intensity={ambientIntensity}
onFallback={handleWebGLFallback}
/>
{:else if useFallback}
<!-- CSS Fallback Renderer -->
<div class="fallback-container" class:loading={isLoading}>
<canvas
bind:this={canvasRef}
class="background-canvas"
aria-hidden="true"
></canvas>
</div>
{:else}
<!-- Loading state while determining renderer -->
<div class="loading-placeholder"></div>
{/if}
<!-- Dark overlay for better contrast -->
<div class="dark-overlay"></div>
@@ -93,19 +157,21 @@
background-color: #0a0a0b;
}
/* CSS Fallback styles */
.fallback-container {
position: absolute;
inset: 0;
}
.background-canvas {
position: absolute;
/* Extend beyond viewport to hide blur edges */
inset: -80px;
width: calc(100% + 160px);
height: calc(100% + 160px);
/* Smooth interpolation when scaling up */
image-rendering: auto;
/* Small blur to smooth out the 64x64 canvas - MUCH cheaper than 120px on full image */
filter: blur(40px);
/* GPU layer for performance */
transform: scale(1.15) translateZ(0);
will-change: opacity, filter;
will-change: opacity;
transition: opacity 500ms ease-out;
}
@@ -113,11 +179,18 @@
opacity: 0;
}
/* Subtle dark overlay */
.loading-placeholder {
position: absolute;
inset: 0;
background-color: #0a0a0b;
}
/* Dark overlay */
.dark-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.15);
pointer-events: none;
z-index: 1;
}
</style>