Files
bedolaga-cabinet/src/components/layout/AppShell/Aurora.tsx
Fringg 23f56afaf7 fix: stretch low-res Aurora canvas to fill viewport
OGL renderer.setSize() sets both the internal buffer and CSS dimensions,
so the 10% resolution canvas was only covering 10% of the screen.
Now explicitly reset canvas.style.width/height to 100% after setSize()
so the small buffer is upscaled by the browser to full viewport.
2026-02-08 17:46:23 +03:00

309 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<HTMLDivElement>(null);
const animationFrameRef = useRef<number>(0);
const rendererRef = useRef<Renderer | null>(null);
const programRef = useRef<Program | null>(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 раз,
т.к. блюрит только один элемент, а не все слои под ним). */}
<div
ref={containerRef}
className="pointer-events-none fixed inset-0 z-0"
style={{
width: '100%',
height: '100%',
filter: 'blur(20px)',
WebkitFilter: 'blur(20px)',
backgroundColor: blurColor,
contain: 'strict',
}}
/>
</>
);
}