From e4a108dc169821495411f31075b41a58a3dec6db Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 1 Feb 2026 11:24:24 -0600 Subject: [PATCH] refactor(immersive): separate blur and motion concerns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - texture-loader.ts: Multi-pass blur (4×15px) at 256x256 for CSS-equivalent quality - ambient.frag: Simplified to UV drift, zoom breathing, and color breathing only - Blur computed ONCE at load time, shader only does motion effects - No per-frame blur, image processing, or texture uploads --- src/lib/immersive/shaders/ambient.frag | 95 +++---------- src/lib/immersive/utils/texture-loader.ts | 163 +++++++++------------- 2 files changed, 88 insertions(+), 170 deletions(-) diff --git a/src/lib/immersive/shaders/ambient.frag b/src/lib/immersive/shaders/ambient.frag index c2329e1ec..ebfeea931 100644 --- a/src/lib/immersive/shaders/ambient.frag +++ b/src/lib/immersive/shaders/ambient.frag @@ -1,13 +1,14 @@ #version 300 es /** - * Ambient Motion Fragment Shader + * Ambient Post-Process Shader * - * Adds GPU-driven motion and blur simulation to the background: - * - Multi-sample blur (samples multiple nearby points) - * - UV drift (slow wandering) - * - Zoom oscillation (breathing effect) - * - Color breathing (subtle brightness pulsing) - * - Noise-based distortion to break up pixelation + * Displays a PRE-BLURRED texture with subtle ambient motion. + * NO blur computation here - the texture is already blurred. + * + * Effects (all UV-based, no per-pixel processing): + * - Slow UV drift + * - Subtle zoom breathing + * - Color intensity breathing */ precision mediump float; @@ -19,85 +20,27 @@ uniform sampler2D u_texture; uniform float u_time; uniform float u_intensity; -// Motion parameters -const float DRIFT_SPEED_X = 0.12; -const float DRIFT_SPEED_Y = 0.09; -const float DRIFT_AMOUNT = 0.06; - -const float ZOOM_SPEED = 0.08; -const float ZOOM_AMOUNT = 0.04; - -const float BREATH_SPEED = 0.15; -const float BREATH_AMOUNT = 0.08; - -// Blur simulation parameters -const float BLUR_RADIUS = 0.025; // UV space blur radius (increased for 256px source) -const int BLUR_SAMPLES = 16; // More samples for smoother blur - -// Simple pseudo-random function -float random(vec2 st) { - return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123); -} - -// Smooth noise function -float noise(vec2 st) { - vec2 i = floor(st); - vec2 f = fract(st); - - float a = random(i); - float b = random(i + vec2(1.0, 0.0)); - float c = random(i + vec2(0.0, 1.0)); - float d = random(i + vec2(1.0, 1.0)); - - vec2 u = f * f * (3.0 - 2.0 * f); - - return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y; -} - void main() { vec2 uv = v_texCoord; vec2 center = vec2(0.5); - // UV drift - float driftX = sin(u_time * DRIFT_SPEED_X) * DRIFT_AMOUNT * u_intensity; - float driftY = cos(u_time * DRIFT_SPEED_Y * 1.3) * DRIFT_AMOUNT * u_intensity; + // Slow UV drift + float driftX = sin(u_time * 0.1) * 0.04 * u_intensity; + float driftY = cos(u_time * 0.08) * 0.03 * u_intensity; uv += vec2(driftX, driftY); - // Zoom oscillation - float zoom = 1.0 + sin(u_time * ZOOM_SPEED) * ZOOM_AMOUNT * u_intensity; + // Subtle zoom breathing + float zoom = 1.0 + sin(u_time * 0.06) * 0.03 * u_intensity; uv = center + (uv - center) / zoom; - // Add subtle noise-based distortion to break up pixelation - float noiseScale = 8.0; - float distortAmount = 0.008; - float n1 = noise(uv * noiseScale + u_time * 0.1); - float n2 = noise(uv * noiseScale + 100.0 + u_time * 0.1); - uv += vec2(n1 - 0.5, n2 - 0.5) * distortAmount; + // Clamp to valid range + uv = clamp(uv, 0.0, 1.0); - // Multi-sample blur to smooth out the texture - vec4 color = vec4(0.0); - float totalWeight = 0.0; + // Sample pre-blurred texture (no blur computation needed) + vec4 color = texture(u_texture, uv); - for (int i = 0; i < BLUR_SAMPLES; i++) { - float angle = float(i) * 6.28318530718 / float(BLUR_SAMPLES); - float r = BLUR_RADIUS * (0.5 + 0.5 * random(uv + float(i))); - vec2 offset = vec2(cos(angle), sin(angle)) * r; - - vec2 sampleUV = clamp(uv + offset, 0.0, 1.0); - float weight = 1.0 - length(offset) / BLUR_RADIUS; - - color += texture(u_texture, sampleUV) * weight; - totalWeight += weight; - } - - // Add center sample with higher weight - color += texture(u_texture, clamp(uv, 0.0, 1.0)) * 2.0; - totalWeight += 2.0; - - color /= totalWeight; - - // Color breathing - float breath = 1.0 + sin(u_time * BREATH_SPEED) * BREATH_AMOUNT * u_intensity; + // Subtle brightness breathing + float breath = 1.0 + sin(u_time * 0.12) * 0.06 * u_intensity; color.rgb *= breath; fragColor = color; diff --git a/src/lib/immersive/utils/texture-loader.ts b/src/lib/immersive/utils/texture-loader.ts index 086a77755..933526341 100644 --- a/src/lib/immersive/utils/texture-loader.ts +++ b/src/lib/immersive/utils/texture-loader.ts @@ -1,13 +1,19 @@ /** * Texture Loader * - * Handles async loading of artwork textures with pre-blur processing. - * Supports request cancellation for rapid track changes. + * Creates a high-quality pre-blurred texture that matches CSS blur(40px) quality. + * The blur is computed ONCE at load time - no per-frame processing. + * + * Strategy: + * 1. Load image at reasonable size (256x256) + * 2. Apply heavy Gaussian-like blur via multiple canvas filter passes + * 3. Apply color adjustments (saturation, brightness) + * 4. Upload to GPU texture ONCE */ import { createTextureFromImage } from './webgl-utils'; -// Cache of loaded blurred images (data URLs) +// Cache of loaded blurred images const blurCache = new Map(); const MAX_CACHE_SIZE = 20; @@ -15,23 +21,44 @@ const MAX_CACHE_SIZE = 20; const activeLoads = new Map(); /** - * Generate a pre-blurred version of an image. - * Uses canvas 2D for blur (one-time CPU cost, then GPU texture). - * - * @param imageUrl - Source image URL - * @param size - Output size (smaller = more blur when scaled) - * @param blurRadius - Canvas filter blur radius - * @param signal - AbortSignal for cancellation + * Apply multiple blur passes to achieve CSS blur(40px) equivalent. + * Uses OffscreenCanvas if available for better performance. + */ +function applyHeavyBlur( + sourceCanvas: HTMLCanvasElement, + passes: number = 4, + blurPerPass: number = 12 +): HTMLCanvasElement { + let current = sourceCanvas; + const size = sourceCanvas.width; + + for (let i = 0; i < passes; i++) { + const next = document.createElement('canvas'); + next.width = size; + next.height = size; + const ctx = next.getContext('2d'); + if (!ctx) return current; + + // Each pass applies blur + ctx.filter = `blur(${blurPerPass}px)`; + ctx.drawImage(current, 0, 0); + + current = next; + } + + return current; +} + +/** + * Generate a pre-blurred background image. + * This creates a texture that looks like CSS filter: blur(40px). */ async function generateBlurredImage( imageUrl: string, - size: number = 64, - blurRadius: number = 20, signal?: AbortSignal ): Promise { // Check cache first - const cacheKey = `${imageUrl}-${size}-${blurRadius}`; - const cached = blurCache.get(cacheKey); + const cached = blurCache.get(imageUrl); if (cached) return cached; return new Promise((resolve, reject) => { @@ -59,88 +86,50 @@ async function generateBlurredImage( } try { - // Multi-pass blur to match CSS blur(40px) quality - // Use larger canvases to allow proper blur spreading + // Step 1: Draw image to initial canvas at target size + // 256x256 provides good quality while keeping processing fast + const SIZE = 256; + const BLUR_PASSES = 4; + const BLUR_PER_PASS = 15; // 4 passes * 15px ≈ CSS blur(40-50px) - // Step 1: Draw image to source canvas const sourceCanvas = document.createElement('canvas'); - sourceCanvas.width = size; - sourceCanvas.height = size; + sourceCanvas.width = SIZE; + sourceCanvas.height = SIZE; const sourceCtx = sourceCanvas.getContext('2d'); if (!sourceCtx) { - reject(new Error('Could not get source canvas context')); - return; - } - sourceCtx.drawImage(img, 0, 0, size, size); - - // Step 2: First blur pass (large canvas to capture blur overflow) - const blurCanvas1 = document.createElement('canvas'); - const expandedSize = size + blurRadius * 4; - blurCanvas1.width = expandedSize; - blurCanvas1.height = expandedSize; - const blurCtx1 = blurCanvas1.getContext('2d'); - if (!blurCtx1) { - reject(new Error('Could not get blur canvas context')); + reject(new Error('Could not get canvas context')); return; } - // Apply first blur pass with color adjustments - blurCtx1.filter = `blur(${blurRadius}px) saturate(1.3) brightness(0.55)`; - blurCtx1.drawImage(sourceCanvas, blurRadius * 2, blurRadius * 2); + // Draw image scaled to fit canvas + sourceCtx.drawImage(img, 0, 0, SIZE, SIZE); - // Step 3: Second blur pass - const blurCanvas2 = document.createElement('canvas'); - blurCanvas2.width = expandedSize; - blurCanvas2.height = expandedSize; - const blurCtx2 = blurCanvas2.getContext('2d'); - if (!blurCtx2) { - reject(new Error('Could not get blur canvas 2 context')); - return; - } + // Step 2: Apply heavy blur (multiple passes) + const blurredCanvas = applyHeavyBlur(sourceCanvas, BLUR_PASSES, BLUR_PER_PASS); - blurCtx2.filter = `blur(${blurRadius}px)`; - blurCtx2.drawImage(blurCanvas1, 0, 0); - - // Step 4: Third blur pass for maximum smoothness - const blurCanvas3 = document.createElement('canvas'); - blurCanvas3.width = expandedSize; - blurCanvas3.height = expandedSize; - const blurCtx3 = blurCanvas3.getContext('2d'); - if (!blurCtx3) { - reject(new Error('Could not get blur canvas 3 context')); - return; - } - - blurCtx3.filter = `blur(${Math.floor(blurRadius / 2)}px)`; - blurCtx3.drawImage(blurCanvas2, 0, 0); - - // Step 5: Crop to final size + // Step 3: Apply color adjustments const finalCanvas = document.createElement('canvas'); - finalCanvas.width = size; - finalCanvas.height = size; + finalCanvas.width = SIZE; + finalCanvas.height = SIZE; const finalCtx = finalCanvas.getContext('2d'); if (!finalCtx) { reject(new Error('Could not get final canvas context')); return; } - // Extract center portion - const offset = blurRadius * 2; - finalCtx.drawImage( - blurCanvas3, - offset, offset, size, size, - 0, 0, size, size - ); + // Saturation boost + brightness reduction for background use + finalCtx.filter = 'saturate(1.3) brightness(0.55)'; + finalCtx.drawImage(blurredCanvas, 0, 0); - // Convert to data URL - const dataUrl = finalCanvas.toDataURL('image/jpeg', 0.7); + // Step 4: Convert to data URL + const dataUrl = finalCanvas.toDataURL('image/jpeg', 0.85); // Cache the result if (blurCache.size >= MAX_CACHE_SIZE) { const firstKey = blurCache.keys().next().value; if (firstKey) blurCache.delete(firstKey); } - blurCache.set(cacheKey, dataUrl); + blurCache.set(imageUrl, dataUrl); resolve(dataUrl); } catch (err) { @@ -158,7 +147,7 @@ async function generateBlurredImage( } /** - * Load an image from a URL or data URL. + * Load image from URL or data URL. */ function loadImage(src: string, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { @@ -198,18 +187,10 @@ export interface LoadTextureResult { } /** - * Load a blurred texture from artwork URL. + * Load a pre-blurred texture from artwork URL. * - * Process: - * 1. Generate pre-blurred image (cached) - * 2. Load as HTMLImageElement - * 3. Upload to WebGL texture - * - * Cancels any previous load for the same request ID. - * - * @param gl - WebGL2 context - * @param artworkUrl - URL of the artwork - * @param requestId - Unique ID for this load request (for cancellation) + * The texture is blurred ONCE at load time. + * No per-frame blur processing needed. */ export async function loadBlurredTexture( gl: WebGL2RenderingContext, @@ -222,30 +203,25 @@ export async function loadBlurredTexture( existingController.abort(); } - // Create new abort controller const controller = new AbortController(); activeLoads.set(requestId, controller); try { - // Generate pre-processed image - // Use 256x256 for good quality - GPU shader will handle the blur + // Generate pre-blurred image (cached) const blurredDataUrl = await generateBlurredImage( artworkUrl, - 256, // Higher quality source for GPU blur - 8, // Light pre-blur, GPU shader does the heavy lifting controller.signal ); // Load as image element const img = await loadImage(blurredDataUrl, controller.signal); - // Create texture + // Create texture (uploaded ONCE) const texture = createTextureFromImage(gl, img); if (!texture) { throw new Error('Failed to create texture'); } - // Clean up active load activeLoads.delete(requestId); return { @@ -256,7 +232,6 @@ export async function loadBlurredTexture( } catch (err) { activeLoads.delete(requestId); - // Don't log abort errors - they're expected during rapid track changes if (err instanceof DOMException && err.name === 'AbortError') { return null; } @@ -284,7 +259,7 @@ export function clearBlurCache(): void { } /** - * Get cache statistics for debugging. + * Get cache statistics. */ export function getBlurCacheStats(): { size: number; maxSize: number } { return {