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:
1 parent
b110516965
commit
e4a108dc16
2 files changed
+88
-170
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user