Files
Webklar/src/components/LightRays.tsx
T
rootandClaude Opus 5.5 5cc8b5f66b 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>
2026-09-25 21:47:17 +00:00

493 lines
14 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.
"use client";
import { useRef, useEffect, useState } from "react";
// @ts-expect-error ogl has no type definitions
import { Renderer, Program, Triangle, Mesh } from "ogl";
import "./LightRays.css";
const DEFAULT_COLOR = "#ffffff";
const hexToRgb = (hex: string): [number, number, number] => {
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return m
? [
parseInt(m[1], 16) / 255,
parseInt(m[2], 16) / 255,
parseInt(m[3], 16) / 255,
]
: [1, 1, 1];
};
type RaysOrigin =
| "top-left"
| "top-right"
| "top-center"
| "left"
| "right"
| "bottom-left"
| "bottom-center"
| "bottom-right";
const getAnchorAndDir = (
origin: RaysOrigin,
w: number,
h: number
): { anchor: [number, number]; dir: [number, number] } => {
const outside = 0.2;
switch (origin) {
case "top-left":
return { anchor: [0, -outside * h], dir: [0, 1] };
case "top-right":
return { anchor: [w, -outside * h], dir: [0, 1] };
case "left":
return { anchor: [-outside * w, 0.5 * h], dir: [1, 0] };
case "right":
return { anchor: [(1 + outside) * w, 0.5 * h], dir: [-1, 0] };
case "bottom-left":
return { anchor: [0, (1 + outside) * h], dir: [0, -1] };
case "bottom-center":
return { anchor: [0.5 * w, (1 + outside) * h], dir: [0, -1] };
case "bottom-right":
return { anchor: [w, (1 + outside) * h], dir: [0, -1] };
default:
return { anchor: [0.5 * w, -outside * h], dir: [0, 1] };
}
};
export interface LightRaysProps {
raysOrigin?: RaysOrigin;
raysColor?: string;
raysSpeed?: number;
lightSpread?: number;
rayLength?: number;
pulsating?: boolean;
fadeDistance?: number;
saturation?: number;
followMouse?: boolean;
mouseInfluence?: number;
noiseAmount?: number;
distortion?: number;
className?: string;
}
export default function LightRays({
raysOrigin = "top-center",
raysColor = DEFAULT_COLOR,
raysSpeed = 1,
lightSpread = 1,
rayLength = 2,
pulsating = false,
fadeDistance = 1.0,
saturation = 1.0,
followMouse = true,
mouseInfluence = 0.1,
noiseAmount = 0.0,
distortion = 0.0,
className = "",
}: LightRaysProps) {
const containerRef = useRef<HTMLDivElement>(null);
const uniformsRef = useRef<Record<string, { value: unknown }> | null>(null);
const rendererRef = useRef<InstanceType<typeof Renderer> | null>(null);
const mouseRef = useRef({ x: 0.5, y: 0.5 });
const smoothMouseRef = useRef({ x: 0.5, y: 0.5 });
const animationIdRef = useRef<number | null>(null);
const meshRef = useRef<InstanceType<typeof Mesh> | null>(null);
const cleanupFunctionRef = useRef<(() => void) | null>(null);
const [isVisible, setIsVisible] = useState(false);
const [useFallback, setUseFallback] = useState(false);
const observerRef = useRef<IntersectionObserver | null>(null);
useEffect(() => {
if (!containerRef.current) return;
observerRef.current = new IntersectionObserver(
(entries) => {
const entry = entries[0];
setIsVisible(entry.isIntersecting);
},
{ threshold: 0.1 }
);
observerRef.current.observe(containerRef.current);
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
};
}, []);
useEffect(() => {
if (!isVisible || !containerRef.current) return;
setUseFallback(false);
if (cleanupFunctionRef.current) {
cleanupFunctionRef.current();
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 () => {
if (!containerRef.current) return;
await new Promise((resolve) => setTimeout(resolve, 10));
if (cancelled || !containerRef.current) return;
const isMobile =
typeof window !== "undefined" &&
(window.innerWidth <= 768 || "ontouchstart" in window);
const dpr = isMobile ? 1 : Math.min(window.devicePixelRatio, 2);
try {
const renderer = new Renderer({
dpr,
alpha: true,
});
rendererRef.current = renderer;
const gl = renderer.gl;
gl.canvas.style.width = "100%";
gl.canvas.style.height = "100%";
while (containerRef.current.firstChild) {
containerRef.current.removeChild(containerRef.current.firstChild);
}
containerRef.current.appendChild(gl.canvas);
const vert = `
attribute vec2 position;
varying vec2 vUv;
void main() {
vUv = position * 0.5 + 0.5;
gl_Position = vec4(position, 0.0, 1.0);
}`;
const frag = `precision mediump float;
uniform float iTime;
uniform vec2 iResolution;
uniform vec2 rayPos;
uniform vec2 rayDir;
uniform vec3 raysColor;
uniform float raysSpeed;
uniform float lightSpread;
uniform float rayLength;
uniform float pulsating;
uniform float fadeDistance;
uniform float saturation;
uniform vec2 mousePos;
uniform float mouseInfluence;
uniform float noiseAmount;
uniform float distortion;
varying vec2 vUv;
float noise(vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898,78.233))) * 43758.5453123);
}
float rayStrength(vec2 raySource, vec2 rayRefDirection, vec2 coord,
float seedA, float seedB, float speed) {
vec2 sourceToCoord = coord - raySource;
vec2 dirNorm = normalize(sourceToCoord);
float cosAngle = dot(dirNorm, rayRefDirection);
float distortedAngle = cosAngle + distortion * sin(iTime * 2.0 + length(sourceToCoord) * 0.01) * 0.2;
float spreadFactor = pow(max(distortedAngle, 0.0), 1.0 / max(lightSpread, 0.001));
float distance = length(sourceToCoord);
float maxDistance = iResolution.x * rayLength;
float lengthFalloff = clamp((maxDistance - distance) / maxDistance, 0.0, 1.0);
float fadeFalloff = clamp((iResolution.x * fadeDistance - distance) / (iResolution.x * fadeDistance), 0.5, 1.0);
float pulse = pulsating > 0.5 ? (0.8 + 0.2 * sin(iTime * speed * 3.0)) : 1.0;
float baseStrength = clamp(
(0.45 + 0.15 * sin(distortedAngle * seedA + iTime * speed)) +
(0.3 + 0.2 * cos(-distortedAngle * seedB + iTime * speed)),
0.0, 1.0
);
return baseStrength * lengthFalloff * fadeFalloff * spreadFactor * pulse;
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 coord = vec2(fragCoord.x, iResolution.y - fragCoord.y);
vec2 finalRayDir = rayDir;
if (mouseInfluence > 0.0) {
vec2 mouseScreenPos = mousePos * iResolution.xy;
vec2 mouseDirection = normalize(mouseScreenPos - rayPos);
finalRayDir = normalize(mix(rayDir, mouseDirection, mouseInfluence));
}
vec4 rays1 = vec4(1.0) *
rayStrength(rayPos, finalRayDir, coord, 36.2214, 21.11349,
1.5 * raysSpeed);
vec4 rays2 = vec4(1.0) *
rayStrength(rayPos, finalRayDir, coord, 22.3991, 18.0234,
1.1 * raysSpeed);
fragColor = rays1 * 0.5 + rays2 * 0.4;
if (noiseAmount > 0.0) {
float n = noise(coord * 0.01 + iTime * 0.1);
fragColor.rgb *= (1.0 - noiseAmount + noiseAmount * n);
}
float brightness = 1.0 - (coord.y / iResolution.y);
fragColor.x *= 0.1 + brightness * 0.8;
fragColor.y *= 0.3 + brightness * 0.6;
fragColor.z *= 0.5 + brightness * 0.5;
if (saturation != 1.0) {
float gray = dot(fragColor.rgb, vec3(0.299, 0.587, 0.114));
fragColor.rgb = mix(vec3(gray), fragColor.rgb, saturation);
}
fragColor.rgb *= raysColor;
}
void main() {
vec4 color;
mainImage(color, gl_FragCoord.xy);
gl_FragColor = color;
}`;
const uniforms = {
iTime: { value: 0 },
iResolution: { value: [1, 1] as [number, number] },
rayPos: { value: [0, 0] as [number, number] },
rayDir: { value: [0, 1] as [number, number] },
raysColor: { value: hexToRgb(raysColor) },
raysSpeed: { value: raysSpeed },
lightSpread: { value: lightSpread },
rayLength: { value: rayLength },
pulsating: { value: pulsating ? 1.0 : 0.0 },
fadeDistance: { value: fadeDistance },
saturation: { value: saturation },
mousePos: { value: [0.5, 0.5] as [number, number] },
mouseInfluence: { value: mouseInfluence },
noiseAmount: { value: noiseAmount },
distortion: { value: distortion },
};
uniformsRef.current = uniforms as Record<string, { value: unknown }>;
const geometry = new Triangle(gl);
const program = new Program(gl, {
vertex: vert,
fragment: frag,
uniforms,
});
const mesh = new Mesh(gl, { geometry, program });
meshRef.current = mesh;
const updatePlacement = () => {
if (!containerRef.current || !renderer) return;
renderer.dpr = isMobile ? 1 : Math.min(window.devicePixelRatio, 2);
const { clientWidth: wCSS, clientHeight: hCSS } = containerRef.current;
renderer.setSize(wCSS, hCSS);
const dpr = renderer.dpr;
const w = wCSS * dpr;
const h = hCSS * dpr;
uniforms.iResolution.value = [w, h];
const { anchor, dir } = getAnchorAndDir(raysOrigin, w, h);
uniforms.rayPos.value = anchor;
uniforms.rayDir.value = dir;
};
const loop = (t: number) => {
if (!rendererRef.current || !uniformsRef.current || !meshRef.current) {
return;
}
const uniforms = uniformsRef.current as typeof uniforms;
uniforms.iTime.value = t * 0.001;
if (followMouse && mouseInfluence > 0.0) {
const smoothing = 0.92;
smoothMouseRef.current.x =
smoothMouseRef.current.x * smoothing +
mouseRef.current.x * (1 - smoothing);
smoothMouseRef.current.y =
smoothMouseRef.current.y * smoothing +
mouseRef.current.y * (1 - smoothing);
uniforms.mousePos.value = [
smoothMouseRef.current.x,
smoothMouseRef.current.y,
];
}
try {
renderer.render({ scene: mesh });
animationIdRef.current = requestAnimationFrame(loop);
} catch (error) {
console.warn("WebGL rendering error:", error);
return;
}
};
window.addEventListener("resize", updatePlacement);
const resizeObserver =
typeof ResizeObserver !== "undefined" &&
new ResizeObserver(() => updatePlacement());
if (resizeObserver && containerRef.current) {
resizeObserver.observe(containerRef.current);
}
updatePlacement();
animationIdRef.current = requestAnimationFrame(loop);
cleanupFunctionRef.current = () => {
if (resizeObserver && containerRef.current) {
resizeObserver.unobserve(containerRef.current);
}
if (animationIdRef.current) {
cancelAnimationFrame(animationIdRef.current);
animationIdRef.current = null;
}
window.removeEventListener("resize", updatePlacement);
if (renderer) {
try {
const canvas = renderer.gl.canvas;
const loseContextExt =
renderer.gl.getExtension("WEBGL_lose_context");
if (loseContextExt) {
loseContextExt.loseContext();
}
if (canvas && canvas.parentNode) {
canvas.parentNode.removeChild(canvas);
}
} catch (error) {
console.warn("Error during WebGL cleanup:", error);
}
}
rendererRef.current = null;
uniformsRef.current = null;
meshRef.current = null;
};
} catch (error) {
console.warn("LightRays WebGL init failed (e.g. on mobile):", error);
setUseFallback(true);
}
};
initializeWebGL();
return () => {
cancelled = true;
if (cleanupFunctionRef.current) {
cleanupFunctionRef.current();
cleanupFunctionRef.current = null;
}
};
}, [
isVisible,
raysOrigin,
raysColor,
raysSpeed,
lightSpread,
rayLength,
pulsating,
fadeDistance,
saturation,
followMouse,
mouseInfluence,
noiseAmount,
distortion,
]);
useEffect(() => {
if (!uniformsRef.current || !containerRef.current || !rendererRef.current)
return;
const u = uniformsRef.current as Record<string, { value: unknown }>;
const renderer = rendererRef.current;
u.raysColor.value = hexToRgb(raysColor);
u.raysSpeed.value = raysSpeed;
u.lightSpread.value = lightSpread;
u.rayLength.value = rayLength;
u.pulsating.value = pulsating ? 1.0 : 0.0;
u.fadeDistance.value = fadeDistance;
u.saturation.value = saturation;
u.mouseInfluence.value = mouseInfluence;
u.noiseAmount.value = noiseAmount;
u.distortion.value = distortion;
const { clientWidth: wCSS, clientHeight: hCSS } = containerRef.current;
const dpr = renderer.dpr;
const { anchor, dir } = getAnchorAndDir(
raysOrigin,
wCSS * dpr,
hCSS * dpr
);
u.rayPos.value = anchor;
u.rayDir.value = dir;
}, [
raysColor,
raysSpeed,
lightSpread,
raysOrigin,
rayLength,
pulsating,
fadeDistance,
saturation,
mouseInfluence,
noiseAmount,
distortion,
]);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!containerRef.current || !rendererRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
mouseRef.current = { x, y };
};
if (followMouse) {
window.addEventListener("mousemove", handleMouseMove);
return () => window.removeEventListener("mousemove", handleMouseMove);
}
}, [followMouse]);
return (
<div
ref={containerRef}
className={`light-rays-container ${className}`.trim()}
>
{useFallback && (
<div
className="light-rays-fallback"
style={{
background: `linear-gradient(to bottom, ${raysColor}50 0%, ${raysColor}20 25%, ${raysColor}08 50%, transparent 85%)`,
}}
aria-hidden
/>
)}
</div>
);
}