mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
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.
309 lines
10 KiB
TypeScript
309 lines
10 KiB
TypeScript
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',
|
||
}}
|
||
/>
|
||
</>
|
||
);
|
||
}
|