refactor(immersive): separate blur and motion concerns

- 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
This commit is contained in:
vicrodh committed 2026-02-01 11:24:24 -06:00
1 parent b110516965
commit e4a108dc16
2 files changed
+88 -170

No files matched your search

+19 -76
View File
@@ -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;
+69 -94
View File
@@ -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<string, string>();
const MAX_CACHE_SIZE = 20;
@@ -15,23 +21,44 @@ const MAX_CACHE_SIZE = 20;
const activeLoads = new Map<string, AbortController>();
/**
* 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<string> {
// 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<HTMLImageElement> {
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 {