Performance: Animationen pausieren, Footer-Raster entschärft, WebGL nachladen

- Footer-Schriftzug (FlickeringGrid): Textmaske einmal pro Größe statt
  ~14.500 getImageData-Aufrufe pro Frame, nur flackernde Quadrate neu
  zeichnen (69–86 ms -> 0,1–0,2 ms pro Frame, Darstellung pixelgleich)
- PixelBlast: WebGL erst in Viewport-Nähe anlegen, unsichtbar pausieren
  (autoPauseOffscreen war nie angeschlossen), Shader asynchron kompilieren,
  beim Unmount aufräumen (Render-Schleifen liefen nach Seitenwechsel
  weiter); kein MSAA, keine erzwungene High-Performance-GPU
- Silk (Hero): nur rendern, solange sichtbar; DPR max. 1,5
- LightRays: keine verwaiste Render-Schleife mehr bei schnellem Vorbeiscrollen
- BorderGlow (Kontaktkarte): Sweep nur, solange die Karte sichtbar ist
- Silk, PixelBlast, LightRays als eigene Chunks (React.lazy):
  Haupt-Bundle 1,6 MB -> 646 KB (gzip 470 -> 207 KB)
- backgroud_effect und Emailsorter-Vorschaubild als WebP
  (974 KB -> 15 KB, 1,2 MB -> 11 KB), Projekt-Vorschaubilder lazy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-25 21:47:17 +00:00
1 parent f74ddfb5ae
commit 5cc8b5f66b
13 files changed
+335 -177

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+20
View File
@@ -127,9 +127,18 @@ const BorderGlow = ({
const angleStart = 110; const angleStart = 110;
const angleEnd = 465; const angleEnd = 465;
let cancelled = false; let cancelled = false;
let running = false;
let visible = typeof IntersectionObserver === 'undefined';
function runSweep() { function runSweep() {
if (cancelled || !card) return; if (cancelled || !card) return;
// Nur sweepen, solange die Karte im Bild ist – sonst malt der Browser
// Maske, Verläufe und Schatten in jedem Frame umsonst neu
if (!visible) {
running = false;
return;
}
running = true;
card.classList.add('sweep-active'); card.classList.add('sweep-active');
card.style.setProperty('--cursor-angle', `${angleStart}deg`); card.style.setProperty('--cursor-angle', `${angleStart}deg`);
animateValue({ duration: 500, onUpdate: v => card.style.setProperty('--edge-proximity', String(v)) }); animateValue({ duration: 500, onUpdate: v => card.style.setProperty('--edge-proximity', String(v)) });
@@ -151,8 +160,19 @@ const BorderGlow = ({
}); });
} }
if (visible) {
runSweep(); runSweep();
return () => { cancelled = true; }; return () => { cancelled = true; };
}
const observer = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
if (visible && !running) runSweep();
});
observer.observe(card);
return () => {
cancelled = true;
observer.disconnect();
};
}, [animated]); }, [animated]);
const glowVars = buildGlowVars(glowColor, glowIntensity); const glowVars = buildGlowVars(glowColor, glowIntensity);
+9 -2
View File
@@ -1,13 +1,15 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ArrowRight } from "lucide-react"; import { ArrowRight } from "lucide-react";
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef, lazy, Suspense } from "react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import Silk from "@/components/Silk";
import CountUp from "@/components/CountUp"; import CountUp from "@/components/CountUp";
import { trackCtaClick } from "@/lib/analytics"; import { trackCtaClick } from "@/lib/analytics";
import { fetchProjects } from "@/lib/projects"; import { fetchProjects } from "@/lib/projects";
// WebGL-Effekte als eigene Chunks laden, damit der Text nicht auf three.js/ogl warten muss
const Silk = lazy(() => import("@/components/Silk"));
const SPARKLE_SVG = ( const SPARKLE_SVG = (
<svg className="btn-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden> <svg className="btn-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden>
<path <path
@@ -88,6 +90,9 @@ const Hero = () => {
<section className="relative min-h-screen flex flex-col justify-center overflow-hidden pt-20"> <section className="relative min-h-screen flex flex-col justify-center overflow-hidden pt-20">
{/* Silk animated background */} {/* Silk animated background */}
<div className="absolute inset-0 z-0 w-full h-full"> <div className="absolute inset-0 z-0 w-full h-full">
<Suspense fallback={null}>
{/* sanft einblenden, weil der Hintergrund jetzt kurz nach dem Text kommt */}
<div className="w-full h-full animate-fade-in">
<Silk <Silk
speed={3} speed={3}
scale={0.5} scale={0.5}
@@ -97,6 +102,8 @@ const Hero = () => {
rotation={0} rotation={0}
/> />
</div> </div>
</Suspense>
</div>
<div className="container mx-auto px-6 relative z-10"> <div className="container mx-auto px-6 relative z-10">
<div className="max-w-6xl"> <div className="max-w-6xl">
+7 -1
View File
@@ -127,12 +127,17 @@ export default function LightRays({
cleanupFunctionRef.current = null; cleanupFunctionRef.current = null;
} }
// Wird der Effekt während der Wartezeit schon wieder aufgeräumt (schnell
// vorbeigescrollt, Seitenwechsel), darf danach keine Render-Schleife mehr
// starten – sonst läuft sie unsichtbar weiter, ohne dass sie jemand beendet.
let cancelled = false;
const initializeWebGL = async () => { const initializeWebGL = async () => {
if (!containerRef.current) return; if (!containerRef.current) return;
await new Promise((resolve) => setTimeout(resolve, 10)); await new Promise((resolve) => setTimeout(resolve, 10));
if (!containerRef.current) return; if (cancelled || !containerRef.current) return;
const isMobile = const isMobile =
typeof window !== "undefined" && typeof window !== "undefined" &&
@@ -390,6 +395,7 @@ void main() {
initializeWebGL(); initializeWebGL();
return () => { return () => {
cancelled = true;
if (cleanupFunctionRef.current) { if (cleanupFunctionRef.current) {
cleanupFunctionRef.current(); cleanupFunctionRef.current();
cleanupFunctionRef.current = null; cleanupFunctionRef.current = null;
+96 -40
View File
@@ -1,6 +1,6 @@
/* eslint-disable react/no-unknown-property */ /* eslint-disable react/no-unknown-property */
import { Effect, EffectComposer, EffectPass, RenderPass } from 'postprocessing'; import { Effect, EffectComposer, EffectPass, RenderPass } from 'postprocessing';
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useState } from 'react';
import * as THREE from 'three'; import * as THREE from 'three';
import './PixelBlast.css'; import './PixelBlast.css';
@@ -255,13 +255,35 @@ interface PixelBlastProps {
noiseAmount?: number; noiseAmount?: number;
} }
type PixelBlastInstance = {
loop: { raf: number; stopped: boolean };
resizeObserver?: ResizeObserver;
quad?: THREE.Mesh;
material: THREE.ShaderMaterial;
composer?: EffectComposer;
renderer: THREE.WebGLRenderer;
};
// Räumt eine PixelBlast-Instanz vollständig ab (Render-Schleife, GPU-Ressourcen, Canvas)
const disposeThree = (t: PixelBlastInstance) => {
t.loop.stopped = true;
cancelAnimationFrame(t.loop.raf);
t.resizeObserver?.disconnect();
t.quad?.geometry.dispose();
t.material.dispose();
t.composer?.dispose();
t.renderer.dispose();
t.renderer.forceContextLoss();
t.renderer.domElement.remove();
};
const PixelBlast = ({ const PixelBlast = ({
variant = 'square', variant = 'square',
pixelSize = 3, pixelSize = 3,
color = '#B497CF', color = '#B497CF',
className, className,
style, style,
antialias = true, antialias = false,
patternScale = 2, patternScale = 2,
patternDensity = 1, patternDensity = 1,
liquid = false, liquid = false,
@@ -280,14 +302,57 @@ const PixelBlast = ({
noiseAmount = 0, noiseAmount = 0,
}: PixelBlastProps) => { }: PixelBlastProps) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const visibilityRef = useRef({ visible: true }); const visibilityRef = useRef({ visible: !autoPauseOffscreen });
const speedRef = useRef(speed); const speedRef = useRef(speed);
const threeRef = useRef<any>(null); const threeRef = useRef<any>(null);
const prevConfigRef = useRef<any>(null); const prevConfigRef = useRef<any>(null);
const [shouldInit, setShouldInit] = useState(!autoPauseOffscreen);
// WebGL erst anlegen, wenn der Abschnitt höchstens eine Bildschirmhöhe
// entfernt ist, und nur rendern, solange er sichtbar ist.
useEffect(() => {
const container = containerRef.current;
if (!container || !autoPauseOffscreen) return;
if (typeof IntersectionObserver === 'undefined') {
visibilityRef.current.visible = true;
setShouldInit(true);
return;
}
const initObserver = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return;
setShouldInit(true);
initObserver.disconnect();
},
{ rootMargin: '100% 0px' }
);
const visibilityObserver = new IntersectionObserver(([entry]) => {
visibilityRef.current.visible = entry.isIntersecting;
if (entry.isIntersecting) threeRef.current?.resume();
});
initObserver.observe(container);
visibilityObserver.observe(container);
return () => {
initObserver.disconnect();
visibilityObserver.disconnect();
};
}, [autoPauseOffscreen]);
// Beim Unmount (Seitenwechsel, Theme-Wechsel) alles abräumen – vorher liefen
// die Render-Schleifen im Hintergrund weiter.
useEffect(
() => () => {
if (threeRef.current) {
disposeThree(threeRef.current);
threeRef.current = null;
}
},
[]
);
useEffect(() => { useEffect(() => {
const container = containerRef.current; const container = containerRef.current;
if (!container) return; if (!container || !shouldInit) return;
speedRef.current = speed; speedRef.current = speed;
const needsReinitKeys = ['antialias', 'liquid', 'noiseAmount']; const needsReinitKeys = ['antialias', 'liquid', 'noiseAmount'];
@@ -301,21 +366,12 @@ const PixelBlast = ({
if (mustReinit) { if (mustReinit) {
if (threeRef.current) { if (threeRef.current) {
const t = threeRef.current; disposeThree(threeRef.current);
t.resizeObserver?.disconnect();
cancelAnimationFrame(t.raf);
t.quad?.geometry.dispose();
t.material.dispose();
t.composer?.dispose();
t.renderer.dispose();
t.renderer.forceContextLoss();
if (t.renderer.domElement.parentElement === container)
container.removeChild(t.renderer.domElement);
threeRef.current = null; threeRef.current = null;
} }
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const renderer = new THREE.WebGLRenderer({ canvas, antialias, alpha: true, powerPreference: 'high-performance' }); const renderer = new THREE.WebGLRenderer({ canvas, antialias, alpha: true });
renderer.domElement.style.width = '100%'; renderer.domElement.style.width = '100%';
renderer.domElement.style.height = '100%'; renderer.domElement.style.height = '100%';
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
@@ -446,12 +502,8 @@ const PixelBlast = ({
renderer.domElement.addEventListener('pointerdown', onPointerDown, { passive: true }); renderer.domElement.addEventListener('pointerdown', onPointerDown, { passive: true });
renderer.domElement.addEventListener('pointermove', onPointerMove, { passive: true }); renderer.domElement.addEventListener('pointermove', onPointerMove, { passive: true });
let raf = 0; const loop = { raf: 0, stopped: false };
const animate = () => { const renderFrame = () => {
if (autoPauseOffscreen && !visibilityRef.current.visible) {
raf = requestAnimationFrame(animate);
return;
}
uniforms.uTime.value = timeOffset + clock.getElapsedTime() * speedRef.current; uniforms.uTime.value = timeOffset + clock.getElapsedTime() * speedRef.current;
if (liquidEffect) liquidEffect.uniforms.get('uTime')!.value = uniforms.uTime.value; if (liquidEffect) liquidEffect.uniforms.get('uTime')!.value = uniforms.uTime.value;
if (composer) { if (composer) {
@@ -462,11 +514,30 @@ const PixelBlast = ({
}); });
composer.render(); composer.render();
} else renderer.render(scene, camera); } else renderer.render(scene, camera);
raf = requestAnimationFrame(animate);
}; };
raf = requestAnimationFrame(animate); const animate = () => {
loop.raf = 0;
if (loop.stopped) return;
// Unsichtbar: Schleife anhalten, der IntersectionObserver startet sie wieder
if (autoPauseOffscreen && !visibilityRef.current.visible) return;
renderFrame();
loop.raf = requestAnimationFrame(animate);
};
const resume = () => {
if (!loop.stopped && !loop.raf) loop.raf = requestAnimationFrame(animate);
};
// Shader im Hintergrund kompilieren (KHR_parallel_shader_compile), statt den
// Main-Thread beim ersten Frame zu blockieren; danach erst rendern
renderer
.compileAsync(scene, camera)
.catch(() => undefined)
.then(() => {
if (loop.stopped) return;
renderFrame();
resume();
});
threeRef.current = { renderer, scene, camera, material, clock, clickIx: 0, uniforms, resizeObserver: ro, raf, quad, timeOffset, composer, touch, liquidEffect }; threeRef.current = { renderer, scene, camera, material, clock, clickIx: 0, uniforms, resizeObserver: ro, loop, resume, quad, timeOffset, composer, touch, liquidEffect };
} else { } else {
const t = threeRef.current; const t = threeRef.current;
t.uniforms.uShapeType.value = SHAPE_MAP[variant] ?? 0; t.uniforms.uShapeType.value = SHAPE_MAP[variant] ?? 0;
@@ -490,26 +561,11 @@ const PixelBlast = ({
} }
prevConfigRef.current = cfg; prevConfigRef.current = cfg;
return () => {
if (threeRef.current && mustReinit) return;
if (!threeRef.current) return;
const t = threeRef.current;
t.resizeObserver?.disconnect();
cancelAnimationFrame(t.raf);
t.quad?.geometry.dispose();
t.material.dispose();
t.composer?.dispose();
t.renderer.dispose();
t.renderer.forceContextLoss();
if (t.renderer.domElement.parentElement === container)
container.removeChild(t.renderer.domElement);
threeRef.current = null;
};
}, [ }, [
antialias, liquid, noiseAmount, pixelSize, patternScale, patternDensity, antialias, liquid, noiseAmount, pixelSize, patternScale, patternDensity,
enableRipples, rippleIntensityScale, rippleThickness, rippleSpeed, enableRipples, rippleIntensityScale, rippleThickness, rippleSpeed,
pixelSizeJitter, edgeFade, transparent, liquidStrength, liquidRadius, pixelSizeJitter, edgeFade, transparent, liquidStrength, liquidRadius,
liquidWobbleSpeed, autoPauseOffscreen, variant, color, speed, liquidWobbleSpeed, autoPauseOffscreen, variant, color, speed, shouldInit,
]); ]);
return ( return (
+9 -3
View File
@@ -1,9 +1,11 @@
import { Calendar, MessageSquareOff, TrendingDown, Folders } from "lucide-react"; import { Calendar, MessageSquareOff, TrendingDown, Folders } from "lucide-react";
import { useEffect, useState } from "react"; import { lazy, Suspense, useEffect, useState } from "react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { LampTop } from "@/components/ui/lamp"; import { LampTop } from "@/components/ui/lamp";
import LightRays from "@/components/LightRays";
import PixelBlast from "@/components/PixelBlast"; // WebGL-Effekte als eigene Chunks laden, damit der Text nicht auf three.js/ogl warten muss
const PixelBlast = lazy(() => import("@/components/PixelBlast"));
const LightRays = lazy(() => import("@/components/LightRays"));
const ProblemSection = () => { const ProblemSection = () => {
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
@@ -40,6 +42,7 @@ const ProblemSection = () => {
{/* PixelBlast animated background - nur im Light Mode */} {/* PixelBlast animated background - nur im Light Mode */}
{isLight && ( {isLight && (
<div className="absolute inset-0 z-0 w-full h-full"> <div className="absolute inset-0 z-0 w-full h-full">
<Suspense fallback={null}>
<PixelBlast <PixelBlast
variant="circle" variant="circle"
pixelSize={3} pixelSize={3}
@@ -52,6 +55,7 @@ const ProblemSection = () => {
edgeFade={0.5} edgeFade={0.5}
transparent transparent
/> />
</Suspense>
</div> </div>
)} )}
{/* Hintergrundbild: nur Blitze rechts, auf Handy maximale Breite */} {/* Hintergrundbild: nur Blitze rechts, auf Handy maximale Breite */}
@@ -65,6 +69,7 @@ const ProblemSection = () => {
{/* LightRays - nur im Dark Mode */} {/* LightRays - nur im Dark Mode */}
{isDark && ( {isDark && (
<div className="absolute inset-0 w-full overflow-hidden z-0"> <div className="absolute inset-0 w-full overflow-hidden z-0">
<Suspense fallback={null}>
<LightRays <LightRays
raysOrigin="top-center" raysOrigin="top-center"
raysColor="#ef4444" raysColor="#ef4444"
@@ -79,6 +84,7 @@ const ProblemSection = () => {
fadeDistance={2} fadeDistance={2}
saturation={2} saturation={2}
/> />
</Suspense>
</div> </div>
)} )}
<LampTop /> <LampTop />
+6 -1
View File
@@ -1,6 +1,9 @@
import { lazy, Suspense } from "react";
import CountUp from "@/components/CountUp"; import CountUp from "@/components/CountUp";
import { TextHoverEffect } from "@/components/ui/text-hover-effect"; import { TextHoverEffect } from "@/components/ui/text-hover-effect";
import PixelBlast from "@/components/PixelBlast";
// WebGL-Effekte als eigene Chunks laden, damit der Text nicht auf three.js/ogl warten muss
const PixelBlast = lazy(() => import("@/components/PixelBlast"));
const Process = () => { const Process = () => {
const steps = [ const steps = [
@@ -30,6 +33,7 @@ const Process = () => {
<section id="process" className="pt-8 pb-24 md:pt-12 md:pb-32 bg-secondary/20 relative"> <section id="process" className="pt-8 pb-24 md:pt-12 md:pb-32 bg-secondary/20 relative">
{/* PixelBlast animated background */} {/* PixelBlast animated background */}
<div className="absolute inset-0 z-0 w-full h-full"> <div className="absolute inset-0 z-0 w-full h-full">
<Suspense fallback={null}>
<PixelBlast <PixelBlast
variant="circle" variant="circle"
pixelSize={3} pixelSize={3}
@@ -49,6 +53,7 @@ const Process = () => {
edgeFade={0.5} edgeFade={0.5}
transparent transparent
/> />
</Suspense>
</div> </div>
{/* TextHoverEffect */} {/* TextHoverEffect */}
<div className="h-[14rem] flex items-center justify-center -mb-4 relative z-10"> <div className="h-[14rem] flex items-center justify-center -mb-4 relative z-10">
+3 -1
View File
@@ -14,7 +14,7 @@ const projects: Project[] = [
{ {
title: "Email Sorter", title: "Email Sorter",
description: "Automatisierung · E-Mail-Workflows für Teams", description: "Automatisierung · E-Mail-Workflows für Teams",
image: "/project%20pics/emailsorter.png", image: "/project%20pics/emailsorter.webp",
url: "https://emailsorter.webklar.com/", url: "https://emailsorter.webklar.com/",
}, },
{ {
@@ -90,6 +90,8 @@ const ProjectShowcase = () => {
<img <img
src={project.image} src={project.image}
alt={project.title} alt={project.title}
loading="lazy"
decoding="async"
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
</div> </div>
+18 -4
View File
@@ -1,6 +1,6 @@
/* eslint-disable react/no-unknown-property */ /* eslint-disable react/no-unknown-property */
import { Canvas, useFrame, useThree } from "@react-three/fiber"; import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { forwardRef, useRef, useMemo, useLayoutEffect } from "react"; import { forwardRef, useRef, useMemo, useLayoutEffect, useEffect, useState } from "react";
import { Color, type Mesh, type ShaderMaterial } from "three"; import { Color, type Mesh, type ShaderMaterial } from "three";
const hexToNormalizedRGB = (hex: string): [number, number, number] => { const hexToNormalizedRGB = (hex: string): [number, number, number] => {
@@ -100,7 +100,8 @@ const SilkPlane = forwardRef<Mesh, SilkPlaneProps>(function SilkPlane(
const mat = ref.current.material as ShaderMaterial & { const mat = ref.current.material as ShaderMaterial & {
uniforms: { uTime: { value: number } }; uniforms: { uTime: { value: number } };
}; };
if (mat.uniforms?.uTime) mat.uniforms.uTime.value += 0.1 * delta; // delta begrenzen, damit das Muster nach einer Pause nicht springt
if (mat.uniforms?.uTime) mat.uniforms.uTime.value += 0.1 * Math.min(delta, 0.1);
} }
}); });
@@ -135,6 +136,17 @@ const Silk = ({
rotation = 0, rotation = 0,
}: SilkProps) => { }: SilkProps) => {
const meshRef = useRef<Mesh>(null); const meshRef = useRef<Mesh>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(true);
// Nur rendern, solange der Hintergrund im Bild ist
useEffect(() => {
const el = wrapperRef.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));
observer.observe(el);
return () => observer.disconnect();
}, []);
const uniforms = useMemo( const uniforms = useMemo(
() => ({ () => ({
@@ -150,14 +162,16 @@ const Silk = ({
); );
return ( return (
<div ref={wrapperRef} style={{ width: "100%", height: "100%" }}>
<Canvas <Canvas
dpr={[1, 2]} dpr={[1, 1.5]}
frameloop="always" frameloop={visible ? "always" : "never"}
style={{ width: "100%", height: "100%" }} style={{ width: "100%", height: "100%" }}
camera={{ position: [0, 0, 5], fov: 75 }} camera={{ position: [0, 0, 5], fov: 75 }}
> >
<SilkPlane ref={meshRef} uniforms={uniforms} /> <SilkPlane ref={meshRef} uniforms={uniforms} />
</Canvas> </Canvas>
</div>
); );
}; };
+9 -3
View File
@@ -1,11 +1,13 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useEffect, useState } from "react"; import { lazy, Suspense, useEffect, useState } from "react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ArrowRight, CheckCircle2 } from "lucide-react"; import { ArrowRight, CheckCircle2 } from "lucide-react";
import { LampTop } from "@/components/ui/lamp"; import { LampTop } from "@/components/ui/lamp";
import LightRays from "@/components/LightRays";
import PixelBlast from "@/components/PixelBlast"; // WebGL-Effekte als eigene Chunks laden, damit der Text nicht auf three.js/ogl warten muss
const PixelBlast = lazy(() => import("@/components/PixelBlast"));
const LightRays = lazy(() => import("@/components/LightRays"));
const SolutionSection = () => { const SolutionSection = () => {
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
@@ -29,6 +31,7 @@ const SolutionSection = () => {
{/* PixelBlast animated background - nur im Light Mode */} {/* PixelBlast animated background - nur im Light Mode */}
{isLight && ( {isLight && (
<div className="absolute inset-0 z-0 w-full h-full"> <div className="absolute inset-0 z-0 w-full h-full">
<Suspense fallback={null}>
<PixelBlast <PixelBlast
variant="circle" variant="circle"
pixelSize={3} pixelSize={3}
@@ -41,6 +44,7 @@ const SolutionSection = () => {
edgeFade={0.5} edgeFade={0.5}
transparent transparent
/> />
</Suspense>
</div> </div>
)} )}
{/* Hintergrundbild mittig, auf Handy maximale Breite */} {/* Hintergrundbild mittig, auf Handy maximale Breite */}
@@ -54,6 +58,7 @@ const SolutionSection = () => {
{/* LightRays - nur im Dark Mode */} {/* LightRays - nur im Dark Mode */}
{isDark && ( {isDark && (
<div className="absolute inset-0 w-full overflow-hidden z-0"> <div className="absolute inset-0 w-full overflow-hidden z-0">
<Suspense fallback={null}>
<LightRays <LightRays
raysOrigin="top-center" raysOrigin="top-center"
raysColor="#22d3ee" raysColor="#22d3ee"
@@ -68,6 +73,7 @@ const SolutionSection = () => {
fadeDistance={2} fadeDistance={2}
saturation={2} saturation={2}
/> />
</Suspense>
</div> </div>
)} )}
<LampTop lineClassName="bg-cyan-400" /> <LampTop lineClassName="bg-cyan-400" />
+1 -1
View File
@@ -46,7 +46,7 @@ const Values = () => {
<div <div
className="values-section-bg absolute inset-0 bg-right bg-no-repeat opacity-[0.3] pointer-events-none" className="values-section-bg absolute inset-0 bg-right bg-no-repeat opacity-[0.3] pointer-events-none"
style={{ style={{
backgroundImage: "url(/backgroud_effect.png)", backgroundImage: "url(/backgroud_effect.webp)",
}} }}
aria-hidden aria-hidden
/> />
+157 -121
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
// Parse a CSS color string ("#RRGGBB", "#RGB", "rgb(r,g,b)") into [r, g, b]. // Parse a CSS color string ("#RRGGBB", "#RGB", "rgb(r,g,b)") into [r, g, b].
@@ -49,6 +49,15 @@ interface FlickeringGridProps extends React.HTMLAttributes<HTMLDivElement> {
fontWeight?: number | string; fontWeight?: number | string;
} }
type Grid = {
cols: number;
rows: number;
dpr: number;
squares: Float32Array;
/** 1 = Quadrat liegt im Schriftzug; wird beim ersten Zeichnen berechnet */
textMask: Uint8Array | null;
};
export const FlickeringGrid: React.FC<FlickeringGridProps> = ({ export const FlickeringGrid: React.FC<FlickeringGridProps> = ({
squareSize = 3, squareSize = 3,
gridGap = 3, gridGap = 3,
@@ -65,101 +74,10 @@ export const FlickeringGrid: React.FC<FlickeringGridProps> = ({
}) => { }) => {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [isInView, setIsInView] = useState(false);
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 });
const rgb = useMemo(() => parseColor(color), [color]); const rgb = useMemo(() => parseColor(color), [color]);
const drawGrid = useCallback(
(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
cols: number,
rows: number,
squares: Float32Array,
dpr: number,
) => {
ctx.clearRect(0, 0, width, height);
const maskCanvas = document.createElement("canvas");
maskCanvas.width = width;
maskCanvas.height = height;
const maskCtx = maskCanvas.getContext("2d", { willReadFrequently: true });
if (!maskCtx) return;
if (text) {
maskCtx.save();
maskCtx.scale(dpr, dpr);
maskCtx.fillStyle = "white";
maskCtx.font = `${fontWeight} ${fontSize}px "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`;
maskCtx.textAlign = "center";
maskCtx.textBaseline = "middle";
maskCtx.fillText(text, width / (2 * dpr), height / (2 * dpr));
maskCtx.restore();
}
for (let i = 0; i < cols; i++) {
for (let j = 0; j < rows; j++) {
const x = i * (squareSize + gridGap) * dpr;
const y = j * (squareSize + gridGap) * dpr;
const squareWidth = squareSize * dpr;
const squareHeight = squareSize * dpr;
const maskData = maskCtx.getImageData(
x,
y,
squareWidth,
squareHeight,
).data;
const hasText = maskData.some(
(value, index) => index % 4 === 0 && value > 0,
);
const opacity = squares[i * rows + j];
const finalOpacity = hasText
? Math.min(1, opacity * 3 + 0.4)
: opacity;
ctx.fillStyle = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${finalOpacity})`;
ctx.fillRect(x, y, squareWidth, squareHeight);
}
}
},
[rgb, squareSize, gridGap, text, fontSize, fontWeight],
);
const setupCanvas = useCallback(
(canvas: HTMLCanvasElement, width: number, height: number) => {
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
const cols = Math.ceil(width / (squareSize + gridGap));
const rows = Math.ceil(height / (squareSize + gridGap));
const squares = new Float32Array(cols * rows);
for (let i = 0; i < squares.length; i++) {
squares[i] = Math.random() * maxOpacity;
}
return { cols, rows, squares, dpr };
},
[squareSize, gridGap, maxOpacity],
);
const updateSquares = useCallback(
(squares: Float32Array, deltaTime: number) => {
for (let i = 0; i < squares.length; i++) {
if (Math.random() < flickerChance * deltaTime) {
squares[i] = Math.random() * maxOpacity;
}
}
},
[flickerChance, maxOpacity],
);
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
const container = containerRef.current; const container = containerRef.current;
@@ -168,38 +86,151 @@ export const FlickeringGrid: React.FC<FlickeringGridProps> = ({
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (!ctx) return; if (!ctx) return;
let animationFrameId: number; const cell = squareSize + gridGap;
let gridParams: ReturnType<typeof setupCanvas>; let grid: Grid | null = null;
let needsFullDraw = true;
let animationFrameId = 0;
let lastTime = 0;
let disposed = false;
const setupCanvas = (w: number, h: number): Grid => {
const dpr = window.devicePixelRatio || 1;
canvas.width = w * dpr;
canvas.height = h * dpr;
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
const cols = Math.ceil(w / cell);
const rows = Math.ceil(h / cell);
const squares = new Float32Array(cols * rows);
for (let i = 0; i < squares.length; i++) {
squares[i] = Math.random() * maxOpacity;
}
return { cols, rows, dpr, squares, textMask: null };
};
// Welche Quadrate liegen im Schriftzug? Früher wurde das in jedem Frame
// mit einem getImageData-Aufruf pro Quadrat neu ermittelt (~14.500 Aufrufe,
// 60–80 ms pro Frame) – jetzt einmal pro Größe mit einem einzigen Aufruf.
const buildTextMask = (g: Grid): Uint8Array => {
const mask = new Uint8Array(g.cols * g.rows);
if (!text) return mask;
const w = canvas.width;
const h = canvas.height;
const maskCanvas = document.createElement("canvas");
maskCanvas.width = w;
maskCanvas.height = h;
const maskCtx = maskCanvas.getContext("2d", { willReadFrequently: true });
if (!maskCtx) return mask;
maskCtx.scale(g.dpr, g.dpr);
maskCtx.fillStyle = "white";
maskCtx.font = `${fontWeight} ${fontSize}px "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`;
maskCtx.textAlign = "center";
maskCtx.textBaseline = "middle";
maskCtx.fillText(text, w / (2 * g.dpr), h / (2 * g.dpr));
const data = maskCtx.getImageData(0, 0, w, h).data;
const size = Math.trunc(squareSize * g.dpr);
for (let i = 0; i < g.cols; i++) {
const x0 = Math.trunc(i * cell * g.dpr);
const x1 = Math.min(x0 + size, w);
for (let j = 0; j < g.rows; j++) {
const y0 = Math.trunc(j * cell * g.dpr);
const y1 = Math.min(y0 + size, h);
let hasText = 0;
for (let y = y0; y < y1 && !hasText; y++) {
for (let x = x0; x < x1; x++) {
if (data[(y * w + x) * 4] > 0) {
hasText = 1;
break;
}
}
}
mask[i * g.rows + j] = hasText;
}
}
return mask;
};
const paintSquare = (g: Grid, i: number, j: number, clear: boolean) => {
const x = i * cell * g.dpr;
const y = j * cell * g.dpr;
const size = squareSize * g.dpr;
if (clear) {
// alle Pixel löschen, die das Quadrat auch nur teilweise berührt; dank
// gridGap reicht das nie bis zum Nachbarquadrat
const x0 = Math.floor(x);
const y0 = Math.floor(y);
ctx.clearRect(x0, y0, Math.ceil(x + size) - x0, Math.ceil(y + size) - y0);
}
const opacity = g.squares[i * g.rows + j];
ctx.globalAlpha = g.textMask?.[i * g.rows + j]
? Math.min(1, opacity * 3 + 0.4)
: opacity;
ctx.fillRect(x, y, size, size);
};
const drawAll = (g: Grid) => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
for (let i = 0; i < g.cols; i++) {
for (let j = 0; j < g.rows; j++) {
paintSquare(g, i, j, false);
}
}
};
// Nur die Quadrate neu zeichnen, die in diesem Frame flackern
const flicker = (g: Grid, deltaTime: number) => {
const chance = flickerChance * deltaTime;
for (let i = 0; i < g.cols; i++) {
for (let j = 0; j < g.rows; j++) {
if (Math.random() < chance) {
g.squares[i * g.rows + j] = Math.random() * maxOpacity;
paintSquare(g, i, j, true);
}
}
}
};
const animate = (time: number) => {
if (grid) {
if (needsFullDraw) {
if (!grid.textMask) grid.textMask = buildTextMask(grid);
drawAll(grid);
needsFullDraw = false;
} else if (lastTime) {
flicker(grid, (time - lastTime) / 1000);
}
}
lastTime = time;
animationFrameId = requestAnimationFrame(animate);
};
const start = () => {
if (animationFrameId) return;
lastTime = 0;
animationFrameId = requestAnimationFrame(animate);
};
const stop = () => {
cancelAnimationFrame(animationFrameId);
animationFrameId = 0;
};
const updateCanvasSize = () => { const updateCanvasSize = () => {
const newWidth = width || container.clientWidth; const newWidth = width || container.clientWidth;
const newHeight = height || container.clientHeight; const newHeight = height || container.clientHeight;
setCanvasSize({ width: newWidth, height: newHeight }); setCanvasSize({ width: newWidth, height: newHeight });
gridParams = setupCanvas(canvas, newWidth, newHeight); grid = setupCanvas(newWidth, newHeight);
needsFullDraw = true;
}; };
updateCanvasSize(); updateCanvasSize();
let lastTime = 0;
const animate = (time: number) => {
if (!isInView) return;
const deltaTime = (time - lastTime) / 1000;
lastTime = time;
updateSquares(gridParams.squares, deltaTime);
drawGrid(
ctx,
canvas.width,
canvas.height,
gridParams.cols,
gridParams.rows,
gridParams.squares,
gridParams.dpr,
);
animationFrameId = requestAnimationFrame(animate);
};
const resizeObserver = new ResizeObserver(() => { const resizeObserver = new ResizeObserver(() => {
updateCanvasSize(); updateCanvasSize();
}); });
@@ -208,23 +239,28 @@ export const FlickeringGrid: React.FC<FlickeringGridProps> = ({
const intersectionObserver = new IntersectionObserver( const intersectionObserver = new IntersectionObserver(
([entry]) => { ([entry]) => {
setIsInView(entry.isIntersecting); if (entry.isIntersecting) start();
else stop();
}, },
{ threshold: 0 }, { threshold: 0 },
); );
intersectionObserver.observe(canvas); intersectionObserver.observe(canvas);
if (isInView) { // Lädt eine Schrift aus dem Font-Stack erst später, Schriftzug neu vermessen
animationFrameId = requestAnimationFrame(animate); document.fonts?.ready.then(() => {
} if (disposed || !grid) return;
grid.textMask = null;
needsFullDraw = true;
});
return () => { return () => {
cancelAnimationFrame(animationFrameId); disposed = true;
stop();
resizeObserver.disconnect(); resizeObserver.disconnect();
intersectionObserver.disconnect(); intersectionObserver.disconnect();
}; };
}, [setupCanvas, updateSquares, drawGrid, width, height, isInView]); }, [width, height, squareSize, gridGap, maxOpacity, flickerChance, rgb, text, fontSize, fontWeight]);
return ( return (
<div <div