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:
1 parent
350352afb9
commit
c94857fc87
3 files changed
+200
-42
No files matched your search
@@ -3,7 +3,7 @@
|
||||
* Immersive Ambient Canvas
|
||||
*
|
||||
* 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:
|
||||
* - WebGL2 context with graceful fallback
|
||||
@@ -11,7 +11,9 @@
|
||||
* - Automatic resize handling
|
||||
* - Performance metrics reporting
|
||||
* - 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';
|
||||
@@ -30,17 +32,27 @@
|
||||
updateTextureCount,
|
||||
handleContextLost,
|
||||
handleContextRestored,
|
||||
getConfig,
|
||||
} from './ImmersiveRenderer';
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
/** URL of the artwork to display */
|
||||
artworkUrl?: string;
|
||||
/** Enable ambient motion (default: true) */
|
||||
enableMotion?: boolean;
|
||||
/** Motion intensity override (0-1, default: from config) */
|
||||
intensity?: number;
|
||||
/** Called when WebGL initialization fails */
|
||||
onFallback?: () => void;
|
||||
}
|
||||
|
||||
let { artworkUrl, onFallback }: Props = $props();
|
||||
let {
|
||||
artworkUrl,
|
||||
enableMotion = true,
|
||||
intensity,
|
||||
onFallback,
|
||||
}: Props = $props();
|
||||
|
||||
// Canvas reference
|
||||
let canvasRef: HTMLCanvasElement | undefined = $state();
|
||||
@@ -51,17 +63,26 @@
|
||||
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
|
||||
let u_texture: WebGLUniformLocation | null = null;
|
||||
let u_time: WebGLUniformLocation | null = null;
|
||||
let u_intensity: WebGLUniformLocation | null = null;
|
||||
|
||||
// Animation state
|
||||
let animationFrameId: number | null = null;
|
||||
let isInitialized = false;
|
||||
let isAnimating = false;
|
||||
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.
|
||||
@@ -77,11 +98,11 @@
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create shader program
|
||||
// Create shader program (use ambient shader for motion)
|
||||
program = createShaderProgram(
|
||||
gl,
|
||||
SHADERS.static.vertex,
|
||||
SHADERS.static.fragment
|
||||
SHADERS.ambient.vertex,
|
||||
SHADERS.ambient.fragment
|
||||
);
|
||||
if (!program) {
|
||||
console.error('[ImmersiveCanvas] Failed to create shader program');
|
||||
@@ -91,6 +112,8 @@
|
||||
|
||||
// Get uniform locations
|
||||
u_texture = gl.getUniformLocation(program, 'u_texture');
|
||||
u_time = gl.getUniformLocation(program, 'u_time');
|
||||
u_intensity = gl.getUniformLocation(program, 'u_intensity');
|
||||
|
||||
// Create fullscreen quad
|
||||
const quad = createFullscreenQuad(gl);
|
||||
@@ -109,8 +132,9 @@
|
||||
canvasRef.addEventListener('webglcontextlost', onContextLost);
|
||||
canvasRef.addEventListener('webglcontextrestored', onContextRestored);
|
||||
|
||||
console.log('[ImmersiveCanvas] WebGL2 initialized successfully');
|
||||
console.log('[ImmersiveCanvas] WebGL2 initialized with ambient shader');
|
||||
isInitialized = true;
|
||||
startTime = performance.now();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -118,13 +142,8 @@
|
||||
* Clean up WebGL resources.
|
||||
*/
|
||||
function destroyWebGL(): void {
|
||||
// Cancel any pending texture loads
|
||||
cancelAllLoads();
|
||||
|
||||
if (animationFrameId !== null) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
stopAnimation();
|
||||
|
||||
if (canvasRef) {
|
||||
canvasRef.removeEventListener('webglcontextlost', onContextLost);
|
||||
@@ -136,7 +155,7 @@
|
||||
program,
|
||||
vao,
|
||||
vertexBuffer,
|
||||
textures: [currentTexture, nextTexture, placeholderTexture],
|
||||
textures: [currentTexture, placeholderTexture],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -145,9 +164,10 @@
|
||||
vao = null;
|
||||
vertexBuffer = null;
|
||||
currentTexture = null;
|
||||
nextTexture = null;
|
||||
placeholderTexture = null;
|
||||
u_texture = null;
|
||||
u_time = null;
|
||||
u_intensity = null;
|
||||
isInitialized = false;
|
||||
}
|
||||
|
||||
@@ -157,12 +177,7 @@
|
||||
function onContextLost(event: Event): void {
|
||||
event.preventDefault();
|
||||
console.warn('[ImmersiveCanvas] WebGL context lost');
|
||||
|
||||
if (animationFrameId !== null) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
animationFrameId = null;
|
||||
}
|
||||
|
||||
stopAnimation();
|
||||
handleContextLost();
|
||||
}
|
||||
|
||||
@@ -171,54 +186,58 @@
|
||||
*/
|
||||
function onContextRestored(): void {
|
||||
console.log('[ImmersiveCanvas] WebGL context restored');
|
||||
|
||||
// Reinitialize everything
|
||||
if (initWebGL()) {
|
||||
handleContextRestored();
|
||||
// Reload current texture
|
||||
if (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.
|
||||
* Handles async loading with cancellation support.
|
||||
*/
|
||||
async function loadTexture(url: string): Promise<void> {
|
||||
if (!gl || !isInitialized) return;
|
||||
|
||||
// Increment load ID for cancellation
|
||||
const loadId = ++textureLoadId;
|
||||
const requestId = `artwork-${loadId}`;
|
||||
|
||||
try {
|
||||
// Load pre-blurred texture
|
||||
const result = await loadBlurredTexture(gl, url, requestId);
|
||||
|
||||
// Check if this load was cancelled (newer load started)
|
||||
if (loadId !== textureLoadId || !result) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Swap textures
|
||||
if (currentTexture) {
|
||||
gl.deleteTexture(currentTexture);
|
||||
}
|
||||
currentTexture = result.texture;
|
||||
lastArtworkUrl = url;
|
||||
|
||||
// Update metrics
|
||||
updateTextureCount(1, result.width * result.height * 4);
|
||||
|
||||
// Render with new texture
|
||||
render();
|
||||
// If not animating, render single frame
|
||||
if (!isAnimating) {
|
||||
render();
|
||||
}
|
||||
|
||||
console.log(`[ImmersiveCanvas] Texture loaded: ${result.width}x${result.height}`);
|
||||
} catch (e) {
|
||||
// Ignore abort errors
|
||||
if (e instanceof DOMException && e.name === 'AbortError') {
|
||||
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.
|
||||
*/
|
||||
function render(): void {
|
||||
if (!gl || !program || !vao || !isInitialized) return;
|
||||
|
||||
const startTime = performance.now();
|
||||
const frameStart = performance.now();
|
||||
|
||||
// Handle resize
|
||||
if (canvasRef) {
|
||||
@@ -242,12 +299,21 @@
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// Use 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
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, currentTexture || placeholderTexture);
|
||||
@@ -259,7 +325,7 @@
|
||||
gl.bindVertexArray(null);
|
||||
|
||||
// Report metrics
|
||||
const frameTime = performance.now() - startTime;
|
||||
const frameTime = performance.now() - frameStart;
|
||||
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(() => {
|
||||
if (initWebGL()) {
|
||||
// Initial render
|
||||
render();
|
||||
// Set up visibility listener
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
|
||||
// Load initial artwork if provided
|
||||
if (artworkUrl) {
|
||||
loadTexture(artworkUrl);
|
||||
}
|
||||
|
||||
// Start animation if motion enabled
|
||||
if (enableMotion) {
|
||||
startAnimation();
|
||||
} else {
|
||||
render();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
destroyWebGL();
|
||||
});
|
||||
</script>
|
||||
@@ -299,7 +385,6 @@
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* Ensure canvas fills container */
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import fullscreenVert from './fullscreen.vert?raw';
|
||||
|
||||
// Fragment shaders
|
||||
import staticFrag from './static.frag?raw';
|
||||
import ambientFrag from './ambient.frag?raw';
|
||||
|
||||
/**
|
||||
* Available shader programs.
|
||||
@@ -27,6 +28,16 @@ export const SHADERS = {
|
||||
vertex: fullscreenVert,
|
||||
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;
|
||||
|
||||
export type ShaderName = keyof typeof SHADERS;
|
||||
Reference in new issue
Block a user