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
|
#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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user