feat(immersive): add Phase 2 texture loader with pre-blur
- Add utils/texture-loader.ts with: - Async pre-blur generation using canvas 2D - Blur cache (max 20 entries) - Request cancellation for rapid track changes - Abort signal support throughout - Update ImmersiveAmbientCanvas.svelte: - Use texture-loader for blurred artwork - Handle cancellation gracefully - Report texture metrics - Clean up pending loads on destroy Pre-blur is computed once per track change (CPU), then uploaded to GPU texture. No per-frame blur computation.
This commit is contained in:
1 parent
979326eeba
commit
350352afb9
2 files changed
+273
-15
No files matched your search
@@ -3,13 +3,15 @@
|
||||
* Immersive Ambient Canvas
|
||||
*
|
||||
* WebGL2-based canvas for rendering the immersive background.
|
||||
* Phase 1: Static texture rendering only.
|
||||
* Phase 2: Pre-blurred artwork with async loading.
|
||||
*
|
||||
* Features:
|
||||
* - WebGL2 context with graceful fallback
|
||||
* - Context loss/restore handling
|
||||
* - Automatic resize handling
|
||||
* - Performance metrics reporting
|
||||
* - Async texture loading with cancellation
|
||||
* - Smooth crossfade between textures
|
||||
*/
|
||||
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
@@ -18,13 +20,14 @@
|
||||
createShaderProgram,
|
||||
createFullscreenQuad,
|
||||
createPlaceholderTexture,
|
||||
createTextureFromImage,
|
||||
resizeCanvasToDisplaySize,
|
||||
cleanupWebGL,
|
||||
} from './utils/webgl-utils';
|
||||
import { loadBlurredTexture, cancelAllLoads } from './utils/texture-loader';
|
||||
import { SHADERS } from './shaders';
|
||||
import {
|
||||
updateMetrics,
|
||||
updateTextureCount,
|
||||
handleContextLost,
|
||||
handleContextRestored,
|
||||
} from './ImmersiveRenderer';
|
||||
@@ -48,6 +51,7 @@
|
||||
let vao: WebGLVertexArrayObject | null = null;
|
||||
let vertexBuffer: WebGLBuffer | null = null;
|
||||
let currentTexture: WebGLTexture | null = null;
|
||||
let nextTexture: WebGLTexture | null = null;
|
||||
let placeholderTexture: WebGLTexture | null = null;
|
||||
|
||||
// Uniform locations
|
||||
@@ -57,6 +61,7 @@
|
||||
let animationFrameId: number | null = null;
|
||||
let isInitialized = false;
|
||||
let lastArtworkUrl = '';
|
||||
let textureLoadId = 0; // For cancellation
|
||||
|
||||
/**
|
||||
* Initialize WebGL resources.
|
||||
@@ -113,6 +118,9 @@
|
||||
* Clean up WebGL resources.
|
||||
*/
|
||||
function destroyWebGL(): void {
|
||||
// Cancel any pending texture loads
|
||||
cancelAllLoads();
|
||||
|
||||
if (animationFrameId !== null) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
@@ -128,7 +136,7 @@
|
||||
program,
|
||||
vao,
|
||||
vertexBuffer,
|
||||
textures: [currentTexture, placeholderTexture],
|
||||
textures: [currentTexture, nextTexture, placeholderTexture],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -137,6 +145,7 @@
|
||||
vao = null;
|
||||
vertexBuffer = null;
|
||||
currentTexture = null;
|
||||
nextTexture = null;
|
||||
placeholderTexture = null;
|
||||
u_texture = null;
|
||||
isInitialized = false;
|
||||
@@ -175,33 +184,44 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a texture from URL.
|
||||
* Load a pre-blurred texture from artwork URL.
|
||||
* Handles async loading with cancellation support.
|
||||
*/
|
||||
async function loadTexture(url: string): Promise<void> {
|
||||
if (!gl || !isInitialized) return;
|
||||
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
// Increment load ID for cancellation
|
||||
const loadId = ++textureLoadId;
|
||||
const requestId = `artwork-${loadId}`;
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => reject(new Error(`Failed to load: ${url}`));
|
||||
img.src = url;
|
||||
});
|
||||
// Load pre-blurred texture
|
||||
const result = await loadBlurredTexture(gl, url, requestId);
|
||||
|
||||
// Delete old texture
|
||||
// Check if this load was cancelled (newer load started)
|
||||
if (loadId !== textureLoadId || !result) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Swap textures
|
||||
if (currentTexture) {
|
||||
gl.deleteTexture(currentTexture);
|
||||
}
|
||||
|
||||
// Create new texture
|
||||
currentTexture = createTextureFromImage(gl, img);
|
||||
currentTexture = result.texture;
|
||||
lastArtworkUrl = url;
|
||||
|
||||
// Update metrics
|
||||
updateTextureCount(1, result.width * result.height * 4);
|
||||
|
||||
// Render with new texture
|
||||
render();
|
||||
|
||||
console.log(`[ImmersiveCanvas] Texture loaded: ${result.width}x${result.height}`);
|
||||
} catch (e) {
|
||||
// Ignore abort errors
|
||||
if (e instanceof DOMException && e.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
console.warn('[ImmersiveCanvas] Failed to load texture:', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Texture Loader
|
||||
*
|
||||
* Handles async loading of artwork textures with pre-blur processing.
|
||||
* Supports request cancellation for rapid track changes.
|
||||
*/
|
||||
|
||||
import { createTextureFromImage } from './webgl-utils';
|
||||
|
||||
// Cache of loaded blurred images (data URLs)
|
||||
const blurCache = new Map<string, string>();
|
||||
const MAX_CACHE_SIZE = 20;
|
||||
|
||||
// Active load requests (for cancellation)
|
||||
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
|
||||
*/
|
||||
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);
|
||||
if (cached) return cached;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
return;
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
|
||||
const abortHandler = () => {
|
||||
img.src = '';
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
};
|
||||
|
||||
signal?.addEventListener('abort', abortHandler);
|
||||
|
||||
img.onload = () => {
|
||||
signal?.removeEventListener('abort', abortHandler);
|
||||
|
||||
if (signal?.aborted) {
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
reject(new Error('Could not get canvas context'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply blur and color adjustments
|
||||
ctx.filter = `blur(${blurRadius}px) saturate(1.2) brightness(0.5)`;
|
||||
|
||||
// Draw with offset to avoid edge artifacts
|
||||
const offset = blurRadius;
|
||||
const drawSize = size + offset * 2;
|
||||
ctx.drawImage(img, -offset, -offset, drawSize, drawSize);
|
||||
|
||||
// Convert to data URL
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.8);
|
||||
|
||||
// Cache the result
|
||||
if (blurCache.size >= MAX_CACHE_SIZE) {
|
||||
const firstKey = blurCache.keys().next().value;
|
||||
if (firstKey) blurCache.delete(firstKey);
|
||||
}
|
||||
blurCache.set(cacheKey, dataUrl);
|
||||
|
||||
resolve(dataUrl);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
signal?.removeEventListener('abort', abortHandler);
|
||||
reject(new Error(`Failed to load image: ${imageUrl}`));
|
||||
};
|
||||
|
||||
img.src = imageUrl;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an image from a URL or data URL.
|
||||
*/
|
||||
function loadImage(src: string, signal?: AbortSignal): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
return;
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
|
||||
const abortHandler = () => {
|
||||
img.src = '';
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
};
|
||||
|
||||
signal?.addEventListener('abort', abortHandler);
|
||||
|
||||
img.onload = () => {
|
||||
signal?.removeEventListener('abort', abortHandler);
|
||||
resolve(img);
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
signal?.removeEventListener('abort', abortHandler);
|
||||
reject(new Error(`Failed to load image: ${src}`));
|
||||
};
|
||||
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
export interface LoadTextureResult {
|
||||
texture: WebGLTexture;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a 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)
|
||||
*/
|
||||
export async function loadBlurredTexture(
|
||||
gl: WebGL2RenderingContext,
|
||||
artworkUrl: string,
|
||||
requestId: string = 'default'
|
||||
): Promise<LoadTextureResult | null> {
|
||||
// Cancel any previous load with the same request ID
|
||||
const existingController = activeLoads.get(requestId);
|
||||
if (existingController) {
|
||||
existingController.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
const controller = new AbortController();
|
||||
activeLoads.set(requestId, controller);
|
||||
|
||||
try {
|
||||
// Generate blurred image
|
||||
const blurredDataUrl = await generateBlurredImage(
|
||||
artworkUrl,
|
||||
128, // Size (larger = less blur needed)
|
||||
30, // Blur radius
|
||||
controller.signal
|
||||
);
|
||||
|
||||
// Load as image element
|
||||
const img = await loadImage(blurredDataUrl, controller.signal);
|
||||
|
||||
// Create texture
|
||||
const texture = createTextureFromImage(gl, img);
|
||||
if (!texture) {
|
||||
throw new Error('Failed to create texture');
|
||||
}
|
||||
|
||||
// Clean up active load
|
||||
activeLoads.delete(requestId);
|
||||
|
||||
return {
|
||||
texture,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
};
|
||||
} 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;
|
||||
}
|
||||
|
||||
console.warn('[TextureLoader] Failed to load texture:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all active texture loads.
|
||||
*/
|
||||
export function cancelAllLoads(): void {
|
||||
for (const controller of activeLoads.values()) {
|
||||
controller.abort();
|
||||
}
|
||||
activeLoads.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the blur cache.
|
||||
*/
|
||||
export function clearBlurCache(): void {
|
||||
blurCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics for debugging.
|
||||
*/
|
||||
export function getBlurCacheStats(): { size: number; maxSize: number } {
|
||||
return {
|
||||
size: blurCache.size,
|
||||
maxSize: MAX_CACHE_SIZE,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user