feat(immersive): add Phase 3 ambient motion shader

- Add shaders/ambient.frag with:
  - UV drift (slow wandering motion)
  - Zoom oscillation (breathing effect)
  - Color breathing (subtle brightness pulsing)
  - Configurable intensity via u_intensity uniform

- Update ImmersiveAmbientCanvas.svelte:
  - Use ambient shader instead of static
  - Add animation loop with requestAnimationFrame
  - Frame throttling at 30fps for power efficiency
  - Visibility-aware: pauses when tab hidden
  - Props: enableMotion, intensity
  - Responds to enableMotion changes dynamically

All motion computation is GPU-side. No CPU per-frame work
except uniform updates (3 floats).
This commit is contained in:
vicrodh committed 2026-02-01 10:12:09 -06:00
1 parent 350352afb9
commit c94857fc87
3 files changed
+200 -42

No files matched your search

+127 -42
View File
@@ -3,7 +3,7 @@
* Immersive Ambient Canvas * Immersive Ambient Canvas
* *
* WebGL2-based canvas for rendering the immersive background. * WebGL2-based canvas for rendering the immersive background.
* Phase 2: Pre-blurred artwork with async loading. * Phase 3: Ambient motion shader with UV drift, zoom, and color breathing.
* *
* Features: * Features:
* - WebGL2 context with graceful fallback * - WebGL2 context with graceful fallback
@@ -11,7 +11,9 @@
* - Automatic resize handling * - Automatic resize handling
* - Performance metrics reporting * - Performance metrics reporting
* - Async texture loading with cancellation * - Async texture loading with cancellation
* - Smooth crossfade between textures * - Ambient motion shader (GPU-only, no CPU per-frame work)
* - Visibility-aware animation (pauses when tab hidden)
* - Configurable motion intensity
*/ */
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
@@ -30,17 +32,27 @@
updateTextureCount, updateTextureCount,
handleContextLost, handleContextLost,
handleContextRestored, handleContextRestored,
getConfig,
} from './ImmersiveRenderer'; } from './ImmersiveRenderer';
// Props // Props
interface Props { interface Props {
/** URL of the artwork to display */ /** URL of the artwork to display */
artworkUrl?: string; artworkUrl?: string;
/** Enable ambient motion (default: true) */
enableMotion?: boolean;
/** Motion intensity override (0-1, default: from config) */
intensity?: number;
/** Called when WebGL initialization fails */ /** Called when WebGL initialization fails */
onFallback?: () => void; onFallback?: () => void;
} }
let { artworkUrl, onFallback }: Props = $props(); let {
artworkUrl,
enableMotion = true,
intensity,
onFallback,
}: Props = $props();
// Canvas reference // Canvas reference
let canvasRef: HTMLCanvasElement | undefined = $state(); let canvasRef: HTMLCanvasElement | undefined = $state();
@@ -51,17 +63,26 @@
let vao: WebGLVertexArrayObject | null = null; let vao: WebGLVertexArrayObject | null = null;
let vertexBuffer: WebGLBuffer | null = null; let vertexBuffer: WebGLBuffer | null = null;
let currentTexture: WebGLTexture | null = null; let currentTexture: WebGLTexture | null = null;
let nextTexture: WebGLTexture | null = null;
let placeholderTexture: WebGLTexture | null = null; let placeholderTexture: WebGLTexture | null = null;
// Uniform locations // Uniform locations
let u_texture: WebGLUniformLocation | null = null; let u_texture: WebGLUniformLocation | null = null;
let u_time: WebGLUniformLocation | null = null;
let u_intensity: WebGLUniformLocation | null = null;
// Animation state // Animation state
let animationFrameId: number | null = null; let animationFrameId: number | null = null;
let isInitialized = false; let isInitialized = false;
let isAnimating = false;
let lastArtworkUrl = ''; let lastArtworkUrl = '';
let textureLoadId = 0; // For cancellation let textureLoadId = 0;
let startTime = 0;
let lastFrameTime = 0;
let isVisible = true;
// Frame throttling for power efficiency
const TARGET_FPS = 30;
const FRAME_INTERVAL = 1000 / TARGET_FPS;
/** /**
* Initialize WebGL resources. * Initialize WebGL resources.
@@ -77,11 +98,11 @@
return false; return false;
} }
// Create shader program // Create shader program (use ambient shader for motion)
program = createShaderProgram( program = createShaderProgram(
gl, gl,
SHADERS.static.vertex, SHADERS.ambient.vertex,
SHADERS.static.fragment SHADERS.ambient.fragment
); );
if (!program) { if (!program) {
console.error('[ImmersiveCanvas] Failed to create shader program'); console.error('[ImmersiveCanvas] Failed to create shader program');
@@ -91,6 +112,8 @@
// Get uniform locations // Get uniform locations
u_texture = gl.getUniformLocation(program, 'u_texture'); u_texture = gl.getUniformLocation(program, 'u_texture');
u_time = gl.getUniformLocation(program, 'u_time');
u_intensity = gl.getUniformLocation(program, 'u_intensity');
// Create fullscreen quad // Create fullscreen quad
const quad = createFullscreenQuad(gl); const quad = createFullscreenQuad(gl);
@@ -109,8 +132,9 @@
canvasRef.addEventListener('webglcontextlost', onContextLost); canvasRef.addEventListener('webglcontextlost', onContextLost);
canvasRef.addEventListener('webglcontextrestored', onContextRestored); canvasRef.addEventListener('webglcontextrestored', onContextRestored);
console.log('[ImmersiveCanvas] WebGL2 initialized successfully'); console.log('[ImmersiveCanvas] WebGL2 initialized with ambient shader');
isInitialized = true; isInitialized = true;
startTime = performance.now();
return true; return true;
} }
@@ -118,13 +142,8 @@
* Clean up WebGL resources. * Clean up WebGL resources.
*/ */
function destroyWebGL(): void { function destroyWebGL(): void {
// Cancel any pending texture loads
cancelAllLoads(); cancelAllLoads();
stopAnimation();
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
if (canvasRef) { if (canvasRef) {
canvasRef.removeEventListener('webglcontextlost', onContextLost); canvasRef.removeEventListener('webglcontextlost', onContextLost);
@@ -136,7 +155,7 @@
program, program,
vao, vao,
vertexBuffer, vertexBuffer,
textures: [currentTexture, nextTexture, placeholderTexture], textures: [currentTexture, placeholderTexture],
}); });
} }
@@ -145,9 +164,10 @@
vao = null; vao = null;
vertexBuffer = null; vertexBuffer = null;
currentTexture = null; currentTexture = null;
nextTexture = null;
placeholderTexture = null; placeholderTexture = null;
u_texture = null; u_texture = null;
u_time = null;
u_intensity = null;
isInitialized = false; isInitialized = false;
} }
@@ -157,12 +177,7 @@
function onContextLost(event: Event): void { function onContextLost(event: Event): void {
event.preventDefault(); event.preventDefault();
console.warn('[ImmersiveCanvas] WebGL context lost'); console.warn('[ImmersiveCanvas] WebGL context lost');
stopAnimation();
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
handleContextLost(); handleContextLost();
} }
@@ -171,54 +186,58 @@
*/ */
function onContextRestored(): void { function onContextRestored(): void {
console.log('[ImmersiveCanvas] WebGL context restored'); console.log('[ImmersiveCanvas] WebGL context restored');
// Reinitialize everything
if (initWebGL()) { if (initWebGL()) {
handleContextRestored(); handleContextRestored();
// Reload current texture
if (lastArtworkUrl) { if (lastArtworkUrl) {
loadTexture(lastArtworkUrl); loadTexture(lastArtworkUrl);
} }
render(); startAnimation();
}
}
/**
* Handle visibility change (pause when tab hidden).
*/
function onVisibilityChange(): void {
isVisible = document.visibilityState === 'visible';
if (isVisible && enableMotion && !isAnimating) {
startAnimation();
} else if (!isVisible) {
stopAnimation();
} }
} }
/** /**
* Load a pre-blurred texture from artwork URL. * Load a pre-blurred texture from artwork URL.
* Handles async loading with cancellation support.
*/ */
async function loadTexture(url: string): Promise<void> { async function loadTexture(url: string): Promise<void> {
if (!gl || !isInitialized) return; if (!gl || !isInitialized) return;
// Increment load ID for cancellation
const loadId = ++textureLoadId; const loadId = ++textureLoadId;
const requestId = `artwork-${loadId}`; const requestId = `artwork-${loadId}`;
try { try {
// Load pre-blurred texture
const result = await loadBlurredTexture(gl, url, requestId); const result = await loadBlurredTexture(gl, url, requestId);
// Check if this load was cancelled (newer load started)
if (loadId !== textureLoadId || !result) { if (loadId !== textureLoadId || !result) {
return; return;
} }
// Swap textures
if (currentTexture) { if (currentTexture) {
gl.deleteTexture(currentTexture); gl.deleteTexture(currentTexture);
} }
currentTexture = result.texture; currentTexture = result.texture;
lastArtworkUrl = url; lastArtworkUrl = url;
// Update metrics
updateTextureCount(1, result.width * result.height * 4); updateTextureCount(1, result.width * result.height * 4);
// Render with new texture // If not animating, render single frame
render(); if (!isAnimating) {
render();
}
console.log(`[ImmersiveCanvas] Texture loaded: ${result.width}x${result.height}`); console.log(`[ImmersiveCanvas] Texture loaded: ${result.width}x${result.height}`);
} catch (e) { } catch (e) {
// Ignore abort errors
if (e instanceof DOMException && e.name === 'AbortError') { if (e instanceof DOMException && e.name === 'AbortError') {
return; return;
} }
@@ -226,13 +245,51 @@
} }
} }
/**
* Start the animation loop.
*/
function startAnimation(): void {
if (isAnimating || !isInitialized) return;
isAnimating = true;
lastFrameTime = performance.now();
animationFrameId = requestAnimationFrame(animationLoop);
console.log('[ImmersiveCanvas] Animation started');
}
/**
* Stop the animation loop.
*/
function stopAnimation(): void {
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
isAnimating = false;
}
/**
* Animation loop with frame throttling.
*/
function animationLoop(timestamp: number): void {
if (!isAnimating) return;
// Frame throttling for power efficiency
const elapsed = timestamp - lastFrameTime;
if (elapsed >= FRAME_INTERVAL) {
lastFrameTime = timestamp - (elapsed % FRAME_INTERVAL);
render();
}
animationFrameId = requestAnimationFrame(animationLoop);
}
/** /**
* Render a single frame. * Render a single frame.
*/ */
function render(): void { function render(): void {
if (!gl || !program || !vao || !isInitialized) return; if (!gl || !program || !vao || !isInitialized) return;
const startTime = performance.now(); const frameStart = performance.now();
// Handle resize // Handle resize
if (canvasRef) { if (canvasRef) {
@@ -242,12 +299,21 @@
} }
// Clear // Clear
gl.clearColor(0.039, 0.039, 0.043, 1.0); // #0a0a0b gl.clearColor(0.039, 0.039, 0.043, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT); gl.clear(gl.COLOR_BUFFER_BIT);
// Use program // Use program
gl.useProgram(program); gl.useProgram(program);
// Set uniforms
const currentTime = (performance.now() - startTime) / 1000; // seconds
const motionIntensity = enableMotion
? (intensity ?? getConfig().ambientIntensity)
: 0;
gl.uniform1f(u_time, currentTime);
gl.uniform1f(u_intensity, motionIntensity);
// Bind texture // Bind texture
gl.activeTexture(gl.TEXTURE0); gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, currentTexture || placeholderTexture); gl.bindTexture(gl.TEXTURE_2D, currentTexture || placeholderTexture);
@@ -259,7 +325,7 @@
gl.bindVertexArray(null); gl.bindVertexArray(null);
// Report metrics // Report metrics
const frameTime = performance.now() - startTime; const frameTime = performance.now() - frameStart;
updateMetrics(frameTime); updateMetrics(frameTime);
} }
@@ -270,19 +336,39 @@
} }
}); });
// React to motion enable/disable
$effect(() => {
if (isInitialized) {
if (enableMotion && isVisible && !isAnimating) {
startAnimation();
} else if (!enableMotion && isAnimating) {
stopAnimation();
render(); // Render one static frame
}
}
});
onMount(() => { onMount(() => {
if (initWebGL()) { if (initWebGL()) {
// Initial render // Set up visibility listener
render(); document.addEventListener('visibilitychange', onVisibilityChange);
// Load initial artwork if provided // Load initial artwork if provided
if (artworkUrl) { if (artworkUrl) {
loadTexture(artworkUrl); loadTexture(artworkUrl);
} }
// Start animation if motion enabled
if (enableMotion) {
startAnimation();
} else {
render();
}
} }
}); });
onDestroy(() => { onDestroy(() => {
document.removeEventListener('visibilitychange', onVisibilityChange);
destroyWebGL(); destroyWebGL();
}); });
</script> </script>
@@ -299,7 +385,6 @@
inset: 0; inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
/* Ensure canvas fills container */
display: block; display: block;
} }
</style> </style>
+62
View File
@@ -0,0 +1,62 @@
#version 300 es
/**
* Ambient Motion Fragment Shader
*
* Adds subtle GPU-driven motion to the background:
* - UV drift (slow wandering)
* - Zoom oscillation (breathing effect)
* - Color breathing (subtle brightness pulsing)
*
* All effects are controlled by u_intensity (0 = static, 1 = full motion).
* All computation is GPU-side - no CPU per-frame work.
*/
precision mediump float;
// Input from vertex shader
in vec2 v_texCoord;
// Output
out vec4 fragColor;
// Uniforms
uniform sampler2D u_texture;
uniform float u_time; // Time in seconds
uniform float u_intensity; // Motion intensity (0.0 - 1.0)
// Constants for motion parameters
const float DRIFT_SPEED_X = 0.08;
const float DRIFT_SPEED_Y = 0.06;
const float DRIFT_AMOUNT = 0.03;
const float ZOOM_SPEED = 0.05;
const float ZOOM_AMOUNT = 0.02;
const float BREATH_SPEED = 0.12;
const float BREATH_AMOUNT = 0.06;
void main() {
vec2 uv = v_texCoord;
vec2 center = vec2(0.5);
// UV drift - slow wandering motion
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;
uv += vec2(driftX, driftY);
// Zoom oscillation - subtle breathing
float zoom = 1.0 + sin(u_time * ZOOM_SPEED) * ZOOM_AMOUNT * u_intensity;
uv = center + (uv - center) / zoom;
// Clamp UV to avoid edge artifacts
uv = clamp(uv, 0.0, 1.0);
// Sample texture
vec4 color = texture(u_texture, uv);
// Color breathing - subtle brightness pulsing
float breath = 1.0 + sin(u_time * BREATH_SPEED) * BREATH_AMOUNT * u_intensity;
color.rgb *= breath;
fragColor = color;
}
+11
View File
@@ -13,6 +13,7 @@ import fullscreenVert from './fullscreen.vert?raw';
// Fragment shaders // Fragment shaders
import staticFrag from './static.frag?raw'; import staticFrag from './static.frag?raw';
import ambientFrag from './ambient.frag?raw';
/** /**
* Available shader programs. * Available shader programs.
@@ -27,6 +28,16 @@ export const SHADERS = {
vertex: fullscreenVert, vertex: fullscreenVert,
fragment: staticFrag, fragment: staticFrag,
}, },
/**
* Ambient shader: adds motion effects.
* UV drift, zoom oscillation, color breathing.
* Requires u_time and u_intensity uniforms.
*/
ambient: {
vertex: fullscreenVert,
fragment: ambientFrag,
},
} as const; } as const;
export type ShaderName = keyof typeof SHADERS; export type ShaderName = keyof typeof SHADERS;