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 #version 300 es
/** /**
* Ambient Motion Fragment Shader * Ambient Post-Process Shader
* *
* Adds GPU-driven motion and blur simulation to the background: * Displays a PRE-BLURRED texture with subtle ambient motion.
* - Multi-sample blur (samples multiple nearby points) * NO blur computation here - the texture is already blurred.
* - UV drift (slow wandering) *
* - Zoom oscillation (breathing effect) * Effects (all UV-based, no per-pixel processing):
* - Color breathing (subtle brightness pulsing) * - Slow UV drift
* - Noise-based distortion to break up pixelation * - Subtle zoom breathing
* - Color intensity breathing
*/ */
precision mediump float; precision mediump float;
@@ -19,85 +20,27 @@ uniform sampler2D u_texture;
uniform float u_time; uniform float u_time;
uniform float u_intensity; 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() { void main() {
vec2 uv = v_texCoord; vec2 uv = v_texCoord;
vec2 center = vec2(0.5); vec2 center = vec2(0.5);
// UV drift // Slow UV drift
float driftX = sin(u_time * DRIFT_SPEED_X) * DRIFT_AMOUNT * u_intensity; float driftX = sin(u_time * 0.1) * 0.04 * u_intensity;
float driftY = cos(u_time * DRIFT_SPEED_Y * 1.3) * DRIFT_AMOUNT * u_intensity; float driftY = cos(u_time * 0.08) * 0.03 * u_intensity;
uv += vec2(driftX, driftY); uv += vec2(driftX, driftY);
// Zoom oscillation // Subtle zoom breathing
float zoom = 1.0 + sin(u_time * ZOOM_SPEED) * ZOOM_AMOUNT * u_intensity; float zoom = 1.0 + sin(u_time * 0.06) * 0.03 * u_intensity;
uv = center + (uv - center) / zoom; uv = center + (uv - center) / zoom;
// Add subtle noise-based distortion to break up pixelation // Clamp to valid range
float noiseScale = 8.0; uv = clamp(uv, 0.0, 1.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;
// Multi-sample blur to smooth out the texture // Sample pre-blurred texture (no blur computation needed)
vec4 color = vec4(0.0); vec4 color = texture(u_texture, uv);
float totalWeight = 0.0;
for (int i = 0; i < BLUR_SAMPLES; i++) { // Subtle brightness breathing
float angle = float(i) * 6.28318530718 / float(BLUR_SAMPLES); float breath = 1.0 + sin(u_time * 0.12) * 0.06 * u_intensity;
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;
color.rgb *= breath; color.rgb *= breath;
fragColor = color; fragColor = color;
+69 -94
View File
@@ -1,13 +1,19 @@
/** /**
* Texture Loader * Texture Loader
* *
* Handles async loading of artwork textures with pre-blur processing. * Creates a high-quality pre-blurred texture that matches CSS blur(40px) quality.
* Supports request cancellation for rapid track changes. * 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'; import { createTextureFromImage } from './webgl-utils';
// Cache of loaded blurred images (data URLs) // Cache of loaded blurred images
const blurCache = new Map<string, string>(); const blurCache = new Map<string, string>();
const MAX_CACHE_SIZE = 20; const MAX_CACHE_SIZE = 20;
@@ -15,23 +21,44 @@ const MAX_CACHE_SIZE = 20;
const activeLoads = new Map<string, AbortController>(); const activeLoads = new Map<string, AbortController>();
/** /**
* Generate a pre-blurred version of an image. * Apply multiple blur passes to achieve CSS blur(40px) equivalent.
* Uses canvas 2D for blur (one-time CPU cost, then GPU texture). * Uses OffscreenCanvas if available for better performance.
* */
* @param imageUrl - Source image URL function applyHeavyBlur(
* @param size - Output size (smaller = more blur when scaled) sourceCanvas: HTMLCanvasElement,
* @param blurRadius - Canvas filter blur radius passes: number = 4,
* @param signal - AbortSignal for cancellation 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( async function generateBlurredImage(
imageUrl: string, imageUrl: string,
size: number = 64,
blurRadius: number = 20,
signal?: AbortSignal signal?: AbortSignal
): Promise<string> { ): Promise<string> {
// Check cache first // Check cache first
const cacheKey = `${imageUrl}-${size}-${blurRadius}`; const cached = blurCache.get(imageUrl);
const cached = blurCache.get(cacheKey);
if (cached) return cached; if (cached) return cached;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -59,88 +86,50 @@ async function generateBlurredImage(
} }
try { try {
// Multi-pass blur to match CSS blur(40px) quality // Step 1: Draw image to initial canvas at target size
// Use larger canvases to allow proper blur spreading // 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'); const sourceCanvas = document.createElement('canvas');
sourceCanvas.width = size; sourceCanvas.width = SIZE;
sourceCanvas.height = size; sourceCanvas.height = SIZE;
const sourceCtx = sourceCanvas.getContext('2d'); const sourceCtx = sourceCanvas.getContext('2d');
if (!sourceCtx) { if (!sourceCtx) {
reject(new Error('Could not get source canvas context')); reject(new Error('Could not get 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'));
return; return;
} }
// Apply first blur pass with color adjustments // Draw image scaled to fit canvas
blurCtx1.filter = `blur(${blurRadius}px) saturate(1.3) brightness(0.55)`; sourceCtx.drawImage(img, 0, 0, SIZE, SIZE);
blurCtx1.drawImage(sourceCanvas, blurRadius * 2, blurRadius * 2);
// Step 3: Second blur pass // Step 2: Apply heavy blur (multiple passes)
const blurCanvas2 = document.createElement('canvas'); const blurredCanvas = applyHeavyBlur(sourceCanvas, BLUR_PASSES, BLUR_PER_PASS);
blurCanvas2.width = expandedSize;
blurCanvas2.height = expandedSize;
const blurCtx2 = blurCanvas2.getContext('2d');
if (!blurCtx2) {
reject(new Error('Could not get blur canvas 2 context'));
return;
}
blurCtx2.filter = `blur(${blurRadius}px)`; // Step 3: Apply color adjustments
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
const finalCanvas = document.createElement('canvas'); const finalCanvas = document.createElement('canvas');
finalCanvas.width = size; finalCanvas.width = SIZE;
finalCanvas.height = size; finalCanvas.height = SIZE;
const finalCtx = finalCanvas.getContext('2d'); const finalCtx = finalCanvas.getContext('2d');
if (!finalCtx) { if (!finalCtx) {
reject(new Error('Could not get final canvas context')); reject(new Error('Could not get final canvas context'));
return; return;
} }
// Extract center portion // Saturation boost + brightness reduction for background use
const offset = blurRadius * 2; finalCtx.filter = 'saturate(1.3) brightness(0.55)';
finalCtx.drawImage( finalCtx.drawImage(blurredCanvas, 0, 0);
blurCanvas3,
offset, offset, size, size,
0, 0, size, size
);
// Convert to data URL // Step 4: Convert to data URL
const dataUrl = finalCanvas.toDataURL('image/jpeg', 0.7); const dataUrl = finalCanvas.toDataURL('image/jpeg', 0.85);
// Cache the result // Cache the result
if (blurCache.size >= MAX_CACHE_SIZE) { if (blurCache.size >= MAX_CACHE_SIZE) {
const firstKey = blurCache.keys().next().value; const firstKey = blurCache.keys().next().value;
if (firstKey) blurCache.delete(firstKey); if (firstKey) blurCache.delete(firstKey);
} }
blurCache.set(cacheKey, dataUrl); blurCache.set(imageUrl, dataUrl);
resolve(dataUrl); resolve(dataUrl);
} catch (err) { } 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> { function loadImage(src: string, signal?: AbortSignal): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => { 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: * The texture is blurred ONCE at load time.
* 1. Generate pre-blurred image (cached) * No per-frame blur processing needed.
* 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)
*/ */
export async function loadBlurredTexture( export async function loadBlurredTexture(
gl: WebGL2RenderingContext, gl: WebGL2RenderingContext,
@@ -222,30 +203,25 @@ export async function loadBlurredTexture(
existingController.abort(); existingController.abort();
} }
// Create new abort controller
const controller = new AbortController(); const controller = new AbortController();
activeLoads.set(requestId, controller); activeLoads.set(requestId, controller);
try { try {
// Generate pre-processed image // Generate pre-blurred image (cached)
// Use 256x256 for good quality - GPU shader will handle the blur
const blurredDataUrl = await generateBlurredImage( const blurredDataUrl = await generateBlurredImage(
artworkUrl, artworkUrl,
256, // Higher quality source for GPU blur
8, // Light pre-blur, GPU shader does the heavy lifting
controller.signal controller.signal
); );
// Load as image element // Load as image element
const img = await loadImage(blurredDataUrl, controller.signal); const img = await loadImage(blurredDataUrl, controller.signal);
// Create texture // Create texture (uploaded ONCE)
const texture = createTextureFromImage(gl, img); const texture = createTextureFromImage(gl, img);
if (!texture) { if (!texture) {
throw new Error('Failed to create texture'); throw new Error('Failed to create texture');
} }
// Clean up active load
activeLoads.delete(requestId); activeLoads.delete(requestId);
return { return {
@@ -256,7 +232,6 @@ export async function loadBlurredTexture(
} catch (err) { } catch (err) {
activeLoads.delete(requestId); activeLoads.delete(requestId);
// Don't log abort errors - they're expected during rapid track changes
if (err instanceof DOMException && err.name === 'AbortError') { if (err instanceof DOMException && err.name === 'AbortError') {
return null; 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 } { export function getBlurCacheStats(): { size: number; maxSize: number } {
return { return {