import { useEffect, useRef } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Renderer, Program, Mesh, Color, Triangle } from 'ogl'; import { brandingApi } from '@/api/branding'; import { themeColorsApi } from '@/api/themeColors'; import { useTheme } from '@/hooks/useTheme'; import { DEFAULT_THEME_COLORS } from '@/types/theme'; const VERT = /* glsl */ `#version 300 es in vec2 position; void main() { gl_Position = vec4(position, 0.0, 1.0); } `; const FRAG = /* glsl */ `#version 300 es precision highp float; uniform float uTime; uniform float uAmplitude; uniform vec3 uColorStops[3]; uniform vec2 uResolution; uniform float uBlend; out vec4 fragColor; vec3 permute(vec3 x) { return mod(((x * 34.0) + 1.0) * x, 289.0); } float snoise(vec2 v) { const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439); vec2 i = floor(v + dot(v, C.yy)); vec2 x0 = v - i + dot(i, C.xx); vec2 i1; i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0); vec4 x12 = x0.xyxy + C.xxzz; x12.xy -= i1; i = mod(i, 289.0); vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0)); vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0); m = m * m; m = m * m; vec3 x = 2.0 * fract(p * C.www) - 1.0; vec3 h = abs(x) - 0.5; vec3 ox = floor(x + 0.5); vec3 a0 = x - ox; m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h); vec3 g; g.x = a0.x * x0.x + h.x * x0.y; g.yz = a0.yz * x12.xz + h.yz * x12.yw; return 130.0 * dot(m, g); } struct ColorStop { vec3 color; float position; }; #define COLOR_RAMP(colors, factor, finalColor) { \ int index = 0; \ for (int i = 0; i < colors.length() - 1; i++) { \ ColorStop currentColor = cyclingColors[i]; \ bool isInBetween = cyclingColors[i].position <= factor; \ index = isInBetween ? i : index; \ } \ ColorStop currentColor = cyclingColors[index]; \ ColorStop nextColor = cyclingColors[index + 1]; \ float range = cyclingColors[index + 1].position - currentColor.position; \ float lerpFactor = (factor - currentColor.position) / range; \ finalColor = mix(currentColor.color, nextColor.color, lerpFactor); \ } void main() { vec2 uv = gl_FragCoord.xy / uResolution; ColorStop cyclingColors[3]; cyclingColors[0] = ColorStop(uColorStops[0], 0.0); cyclingColors[1] = ColorStop(uColorStops[1], 0.5); cyclingColors[2] = ColorStop(uColorStops[2], 1.0); float noiseValue = snoise(uv * uAmplitude + uTime) * 0.5 + 0.5; vec3 rampColor; COLOR_RAMP(cyclingColors, noiseValue, rampColor); fragColor = vec4(rampColor, uBlend); } `; function hexToRgb(hex: string): [number, number, number] { hex = hex.replace('#', ''); if (hex.length === 3) { hex = hex .split('') .map((c) => c + c) .join(''); } const r = parseInt(hex.substring(0, 2), 16) / 255; const g = parseInt(hex.substring(2, 4), 16) / 255; const b = parseInt(hex.substring(4, 6), 16) / 255; return [r, g, b]; } // Reduce lightness of a hex color for subdued background blobs function dimAccent(hex: string, factor = 0.45): string { hex = hex.replace('#', ''); if (hex.length === 3) { hex = hex .split('') .map((c) => c + c) .join(''); } const r = Math.round(parseInt(hex.substring(0, 2), 16) * factor); const g = Math.round(parseInt(hex.substring(2, 4), 16) * factor); const b = Math.round(parseInt(hex.substring(4, 6), 16) * factor); return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; } function generateColorStops(background: string, surface: string, accent: string): string[] { return [background, surface, dimAccent(accent)]; } export function Aurora() { const containerRef = useRef(null); const animationFrameRef = useRef(0); const rendererRef = useRef(null); const programRef = useRef(null); // Fetch animation setting const { data: animationSetting } = useQuery({ queryKey: ['animation-enabled'], queryFn: brandingApi.getAnimationEnabled, staleTime: 60000, }); const isEnabled = animationSetting?.enabled ?? false; // Subscribe reactively to theme-colors cache so Aurora re-renders on setQueryData const { data: themeColors = DEFAULT_THEME_COLORS } = useQuery({ queryKey: ['theme-colors'], queryFn: themeColorsApi.getColors, staleTime: 5 * 60 * 1000, }); // Pick background and surface based on current theme const { isDark } = useTheme(); const background = isDark ? themeColors.darkBackground : themeColors.lightBackground; const surface = isDark ? themeColors.darkSurface : themeColors.lightSurface; // Refs for initial color values (WebGL context shouldn't recreate on color change) const backgroundRef = useRef(background); backgroundRef.current = background; const surfaceRef = useRef(surface); surfaceRef.current = surface; const accentRef = useRef(themeColors.accent); accentRef.current = themeColors.accent; // Initialize WebGL context once (only depends on isEnabled) useEffect(() => { if (!isEnabled || !containerRef.current) return; // Check for reduced motion preference const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (prefersReducedMotion) return; const container = containerRef.current; const renderer = new Renderer({ alpha: true, antialias: false, powerPreference: 'low-power', }); rendererRef.current = renderer; const gl = renderer.gl; gl.clearColor(0, 0, 0, 0); container.appendChild(gl.canvas); const geometry = new Triangle(gl); const colorStops = generateColorStops( backgroundRef.current, surfaceRef.current, accentRef.current, ); const colorStopsArray = colorStops .map((hex) => { const c = new Color(hex); return [c.r, c.g, c.b]; }) .flat(); const program = new Program(gl, { vertex: VERT, fragment: FRAG, uniforms: { uTime: { value: 0 }, uAmplitude: { value: 1.0 }, uColorStops: { value: colorStopsArray }, uResolution: { value: [container.offsetWidth, container.offsetHeight] }, uBlend: { value: 1.0 }, }, }); programRef.current = program; const mesh = new Mesh(gl, { geometry, program }); // Рендерим на 10% разрешения — шейдер обрабатывает в ~100 раз меньше пикселей. // renderer.setSize задаёт и буфер, и CSS-размер канваса, поэтому после него // принудительно возвращаем CSS на 100% — канвас растягивается браузером, // а filter: blur() сглаживает артефакты масштабирования. const RESOLUTION_SCALE = 0.1; function resize() { if (!containerRef.current || !rendererRef.current || !programRef.current) return; const fullW = containerRef.current.offsetWidth; const fullH = containerRef.current.offsetHeight; const bufW = Math.max(Math.ceil(fullW * RESOLUTION_SCALE), 1); const bufH = Math.max(Math.ceil(fullH * RESOLUTION_SCALE), 1); rendererRef.current.setSize(bufW, bufH); // OGL setSize ставит canvas.style.width/height = bufW/bufH px, // перезаписываем на 100% чтобы CSS растянул маленький буфер на весь экран const canvas = rendererRef.current.gl.canvas as HTMLCanvasElement; canvas.style.width = '100%'; canvas.style.height = '100%'; programRef.current.uniforms.uResolution.value = [bufW, bufH]; } window.addEventListener('resize', resize); resize(); let lastTime = 0; const targetFPS = 20; const frameInterval = 1000 / targetFPS; const speed = 0.3; function animate(currentTime: number) { animationFrameRef.current = requestAnimationFrame(animate); const delta = currentTime - lastTime; if (delta < frameInterval) return; lastTime = currentTime - (delta % frameInterval); if (programRef.current && rendererRef.current) { programRef.current.uniforms.uTime.value += speed * 0.01; rendererRef.current.render({ scene: mesh }); } } animationFrameRef.current = requestAnimationFrame(animate); return () => { window.removeEventListener('resize', resize); cancelAnimationFrame(animationFrameRef.current); if (rendererRef.current && container.contains(rendererRef.current.gl.canvas)) { container.removeChild(rendererRef.current.gl.canvas); } rendererRef.current = null; programRef.current = null; }; }, [isEnabled]); // Update color uniforms reactively without recreating WebGL context useEffect(() => { if (!programRef.current) return; const colorStops = generateColorStops(background, surface, themeColors.accent); const colorStopsArray = colorStops .map((hex) => { const c = new Color(hex); return [c.r, c.g, c.b]; }) .flat(); programRef.current.uniforms.uColorStops.value = colorStopsArray; }, [themeColors.accent, background, surface]); if (!isEnabled) { return null; } // Blur overlay color from accent (very subtle) const [r, g, b] = hexToRgb(themeColors.accent); const blurColor = `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, 0.03)`; return ( <> {/* WebGL Aurora canvas — рендерится на ~10% разрешения, CSS растягивает обратно. filter: blur() сглаживает артефакты масштабирования (дешевле backdrop-filter в ~10-20 раз, т.к. блюрит только один элемент, а не все слои под ним). */}
); }