echoes/frontend/hooks/ParticleImage.tsx

1539 lines
43 KiB
TypeScript
Raw Permalink 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, useState, useCallback } from "react";
import * as THREE from "three";
import { gsap } from "gsap";
import throttle from "lodash/throttle";
interface Particle {
x: number;
y: number;
z: number;
originalX: number;
originalY: number;
originalColor: THREE.Color;
delay: number;
}
const particleLoadQueue = new Set<string>();
let lastAnimationTime = 0;
const ANIMATION_THRESHOLD = 300; // 300ms的阈值
const MIN_DELAY = 100; // 最小延迟时间
const createErrorParticles = (width: number, height: number) => {
const particles: Particle[] = [];
const positionArray: number[] = [];
const colorArray: number[] = [];
const errorColor = new THREE.Color(0.8, 0, 0);
const size = Math.min(width, height);
const scaleFactor = size * 0.35;
const particlesPerLine = 50;
// X 形状的两条线
const lines = [
// 左上到右下的线
{ start: [-1, 1], end: [1, -1] },
// 右上到左下的线
{ start: [1, 1], end: [-1, -1] },
];
lines.forEach((line) => {
for (let i = 0; i < particlesPerLine; i++) {
const t = i / (particlesPerLine - 1);
const x = line.start[0] + (line.end[0] - line.start[0]) * t;
const y = line.start[1] + (line.end[1] - line.start[1]) * t;
// 添加一些随机偏移
const randomOffset = 0.1;
const randomX = x + (Math.random() - 0.5) * randomOffset;
const randomY = y + (Math.random() - 0.5) * randomOffset;
const scaledX = randomX * scaleFactor;
const scaledY = randomY * scaleFactor;
particles.push({
x: scaledX,
y: scaledY,
z: 0,
originalX: scaledX,
originalY: scaledY,
originalColor: errorColor,
delay: 0,
});
// 修改初始位置生成方式
const angle = Math.random() * Math.PI * 2;
const distance = size * 2;
positionArray.push(
Math.cos(angle) * distance,
Math.sin(angle) * distance,
0,
);
// 初始颜色设置为最终颜色的一半亮度
colorArray.push(
errorColor.r * 0.5,
errorColor.g * 0.5,
errorColor.b * 0.5,
);
}
});
const particleSize = Math.max(1.2, (size / 200) * 1.2);
return { particles, positionArray, colorArray, particleSize };
};
// 修改 createSmileParticles 函数
const createSmileParticles = (width: number, height: number) => {
const particles: Particle[] = [];
const positionArray: number[] = [];
const colorArray: number[] = [];
const size = Math.min(width, height);
const scale = size / 200;
const radius = size * 0.4;
const particleSize = Math.max(1.2, scale * 1.2);
const particleColor = new THREE.Color(0.8, 0.6, 0);
// 预先计算所有需要的粒子位置
const allPoints: { x: number; y: number }[] = [];
// 计算脸部轮廓的点
const outlinePoints = Math.floor(60 * scale);
const offsetX = radius * 0.05; // 添加水平偏移
const offsetY = radius * 0.05; // 添加垂直偏移
for (let i = 0; i < outlinePoints; i++) {
const angle = (i / outlinePoints) * Math.PI * 2;
allPoints.push({
x: Math.cos(angle) * radius + offsetX,
y: Math.sin(angle) * radius + offsetY,
});
}
// 改眼睛的生成方式
const eyeOffset = radius * 0.3;
const eyeY = radius * 0.15 + offsetY;
const eyeSize = radius * 0.1; // 稍微减小眼睛尺寸
const eyePoints = Math.floor(20 * scale);
[-1, 1].forEach((side) => {
// 使用同心圆的方式生成眼睛
const eyeCenterX = side * eyeOffset + offsetX;
const rings = 3; // 同心圆的数量
for (let ring = 0; ring < rings; ring++) {
const ringRadius = eyeSize * (1 - ring / rings); // 从外到内递减半径
const pointsInRing = Math.floor(eyePoints / rings);
for (let i = 0; i < pointsInRing; i++) {
const angle = (i / pointsInRing) * Math.PI * 2;
allPoints.push({
x: eyeCenterX + Math.cos(angle) * ringRadius,
y: eyeY + Math.sin(angle) * ringRadius,
});
}
}
// 添加中心点
allPoints.push({
x: eyeCenterX,
y: eyeY,
});
});
// 计算嘴巴的点
const smileWidth = radius * 0.6;
const smileY = -radius * 0.35 + offsetY;
const smilePoints = Math.floor(25 * scale);
for (let i = 0; i < smilePoints; i++) {
const t = i / (smilePoints - 1);
const x = (t * 2 - 1) * smileWidth + offsetX;
const y = smileY + Math.pow(x / smileWidth, 2) * radius * 0.2 + offsetY;
allPoints.push({ x, y });
}
// 为所有点创建粒子
allPoints.forEach((point) => {
particles.push({
x: point.x,
y: point.y,
z: 0,
originalX: point.x,
originalY: point.y,
originalColor: particleColor,
delay: 0,
});
// 生成初始位置(从外围圆形区域开始)
const initAngle = Math.random() * Math.PI * 2;
const distance = size * 2;
positionArray.push(
Math.cos(initAngle) * distance,
Math.sin(initAngle) * distance,
0,
);
// 初始颜色设置为最终颜色的一半亮度
colorArray.push(
particleColor.r * 0.5,
particleColor.g * 0.5,
particleColor.b * 0.5,
);
});
return { particles, positionArray, colorArray, particleSize };
};
// 在文件开头添加新的 helper 函数
const easeOutCubic = (t: number) => {
return 1 - Math.pow(1 - t, 3);
};
const customEase = (t: number) => {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
};
// 在文件开头添加新的 LoaderStatus 接口
interface LoaderStatus {
isLoading: boolean;
hasError: boolean;
timeoutError: boolean;
}
// 修改 ParticleImage 组件的 props 接口
interface ParticleImageProps {
src?: string;
status?: LoaderStatus;
onLoad?: () => void;
onAnimationComplete?: () => void;
}
// 修改 BG_CONFIG添加尺寸配置
const BG_CONFIG = {
className:
"bg-gradient-to-br from-[rgb(248,250,252)] via-[rgb(241,245,249)] to-[rgb(236,241,247)] dark:from-[rgb(10,37,77)] dark:via-[rgb(8,27,57)] dark:to-[rgb(2,8,23)]",
};
// 修改采样率和粒子大小计算函数
const getOptimalImageParams = (width: number, height: number) => {
const totalPixels = width * height;
const pixelRatio = window.devicePixelRatio || 1;
const isMobile = window.innerWidth <= 768;
// 移动端使用更大的采样间隔来减少数量
let samplingGap = isMobile
? Math.ceil(Math.max(width, height) / 60) // 移动端降低采样密度
: Math.ceil(Math.max(width, height) / 120); // 桌面端保持较高采密度
// 限制最小采样间隔,避免粒子过多
samplingGap = Math.max(samplingGap, isMobile ? 4 : 2);
// 计算粒子大小
const size = Math.min(width, height);
const scale = size / 200;
// 移动端适当增大粒子以保持视觉效果
const particleSize = isMobile
? Math.max(2, scale * 2.2)
: Math.max(1.5, scale * (1.8 / pixelRatio));
return {
samplingGap,
particleSize,
// 移动端使用较短的动画时间减少性能开销
animationDuration: isMobile ? 0.6 : 0.8,
// 移动端减少延迟时间,使动画更快完成
delayMultiplier: isMobile ? 0.3 : 0.6,
};
};
// 添加资源清理函数
const cleanupResources = (scene: THREE.Scene) => {
scene.traverse((object) => {
if (object instanceof THREE.Points) {
const geometry = object.geometry;
const material = object.material as THREE.PointsMaterial;
// 清空几何体缓冲区
if (geometry.attributes.position) {
geometry.attributes.position.array = new Float32Array(0);
}
if (geometry.attributes.color) {
geometry.attributes.color.array = new Float32Array(0);
}
geometry.dispose();
material.dispose();
// 移除所有属性
geometry.deleteAttribute("position");
geometry.deleteAttribute("color");
}
});
scene.clear();
};
export const ParticleImage = ({
src,
status,
onLoad,
onAnimationComplete,
}: ParticleImageProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const sceneRef = useRef<THREE.Scene>();
const cameraRef = useRef<THREE.OrthographicCamera>();
const rendererRef = useRef<THREE.WebGLRenderer>();
const animationFrameRef = useRef<number>();
const animationTimeoutRef = useRef<NodeJS.Timeout>();
const isAnimatingRef = useRef(false); // 添加动画状态控制
// 添加一个 ref 来追踪组件的挂载状态
const isMountedRef = useRef(true);
// 修改动画控制函数
const startAnimation = useCallback(
(
positionAttribute: THREE.BufferAttribute,
particles: Particle[],
width: number,
height: number,
) => {
if (isAnimatingRef.current) return;
isAnimatingRef.current = true;
const { animationDuration, delayMultiplier } = getOptimalImageParams(
width,
height,
);
const animations: gsap.core.Tween[] = [];
// 获取当前场景中的 Points 对象
const points = sceneRef.current?.children[0] as THREE.Points;
if (!points) return;
const material = points.material as THREE.PointsMaterial;
// 添加材质透明度动画
const materialTween = gsap.to(material, {
opacity: 1,
duration: 0.3,
ease: "power2.out",
});
animations.push(materialTween);
particles.forEach((particle, i) => {
const i3 = i * 3;
const distanceToCenter = Math.sqrt(
Math.pow(particle.originalX, 2) + Math.pow(particle.originalY, 2),
);
const maxDistance = Math.sqrt(
Math.pow(width / 2, 2) + Math.pow(height / 2, 2),
);
const normalizedDistance = distanceToCenter / maxDistance;
const tween = gsap.to(positionAttribute.array, {
duration: animationDuration,
delay: normalizedDistance * delayMultiplier,
[i3]: particle.originalX,
[i3 + 1]: particle.originalY,
[i3 + 2]: 0,
ease: "power2.inOut",
onUpdate: () => {
positionAttribute.needsUpdate = true;
},
});
animations.push(tween);
});
if (animationTimeoutRef.current) {
clearTimeout(animationTimeoutRef.current);
}
const maxDelay = Math.max(
...animations.map((tween) => tween.delay() || 0),
);
// 在动画即将结束时开始淡出粒子
const fadeOutDelay = (maxDelay + animationDuration) * 1000 - 200;
setTimeout(() => {
gsap.to(material, {
opacity: 0,
duration: 0.3,
ease: "power2.in",
});
}, fadeOutDelay);
// 确保在粒子完全消失后才触发完成回调
animationTimeoutRef.current = setTimeout(() => {
requestAnimationFrame(() => {
isAnimatingRef.current = false;
if (onAnimationComplete) {
onAnimationComplete();
}
});
}, fadeOutDelay + 300);
},
[onAnimationComplete],
);
// 修改清理函数
const cleanup = useCallback(() => {
if (!isMountedRef.current) return;
// 检查是否应该跳过清理
if (
sceneRef.current?.userData.isSmileComplete ||
sceneRef.current?.userData.isErrorComplete
) {
return;
}
// 清理动画状态
isAnimatingRef.current = false;
// 清理超时
if (animationTimeoutRef.current) {
clearTimeout(animationTimeoutRef.current);
}
// 取消动画帧
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = undefined;
}
// 清理 GSAP 动画
gsap.killTweensOf("*");
// 清理场景资源
if (sceneRef.current) {
// 检查是否应该跳过清理
if (
!sceneRef.current.userData.isSmileComplete &&
!sceneRef.current.userData.isErrorComplete
) {
cleanupResources(sceneRef.current);
}
}
// 修改渲染器清理逻辑
if (
rendererRef.current &&
!sceneRef.current?.userData.isSmileComplete &&
!sceneRef.current?.userData.isErrorComplete
) {
const renderer = rendererRef.current;
// 确保在移除 DOM 元素前停止渲染
renderer.setAnimationLoop(null);
// 清理渲染器上下文
renderer.dispose();
renderer.forceContextLoss();
// 安全地移除 DOM 元素
const domElement = renderer.domElement;
if (containerRef.current?.contains(domElement)) {
requestAnimationFrame(() => {
if (
isMountedRef.current &&
containerRef.current?.contains(domElement)
) {
try {
containerRef.current.removeChild(domElement);
} catch (e) {
console.warn("清理渲染器DOM元素失败:", e);
}
}
});
}
// 清空引用
rendererRef.current = undefined;
}
// 清理相机引用
if (
cameraRef.current &&
!sceneRef.current?.userData.isSmileComplete &&
!sceneRef.current?.userData.isErrorComplete
) {
cameraRef.current = undefined;
}
}, []);
// 修改 useEffect 的清理
useEffect(() => {
return () => {
cleanup();
};
}, [cleanup]);
// 修改 updateParticles 函数
const updateParticles = useCallback(
(width: number, height: number, instanceId: string) => {
if (!sceneRef.current || isAnimatingRef.current || !isMountedRef.current)
return;
// 只有当src不为空时才执行cleanup
if (src !== "") {
cleanup();
}
if (!isMountedRef.current) return;
const { particles, positionArray, colorArray, particleSize } =
createSmileParticles(width, height);
const geometry = new THREE.BufferGeometry();
const material = new THREE.PointsMaterial({
size: particleSize,
vertexColors: true,
transparent: true,
opacity: 1,
sizeAttenuation: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
depthTest: false,
});
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(positionArray, 3),
);
geometry.setAttribute(
"color",
new THREE.Float32BufferAttribute(colorArray, 3),
);
const points = new THREE.Points(geometry, material);
sceneRef.current.add(points);
const positionAttribute = geometry.attributes
.position as THREE.BufferAttribute;
// 记录完成的动画数量
let completedAnimations = 0;
const totalAnimations = particles.length;
particles.forEach((particle, i) => {
const i3 = i * 3;
const distanceToCenter = Math.sqrt(
Math.pow(particle.originalX, 2) + Math.pow(particle.originalY, 2),
);
const maxDistance = Math.sqrt(
Math.pow(width / 2, 2) + Math.pow(height / 2, 2),
);
const normalizedDistance = distanceToCenter / maxDistance;
gsap.to(positionAttribute.array, {
duration: 0.8,
delay: normalizedDistance * 0.6,
[i3]: particle.originalX,
[i3 + 1]: particle.originalY,
[i3 + 2]: 0,
ease: "sine.inOut",
onUpdate: () => {
positionAttribute.needsUpdate = true;
},
onComplete: () => {
completedAnimations++;
if (completedAnimations === totalAnimations && sceneRef.current) {
sceneRef.current.userData.isSmileComplete = true;
loadingQueue.remove("", instanceId);
}
},
});
});
},
[cleanup, src],
);
// 将 handleResize 移到 ParticleImage 组件内部
const handleResize = useCallback(() => {
if (
!containerRef.current ||
!cameraRef.current ||
!rendererRef.current ||
!sceneRef.current ||
!isMountedRef.current
)
return;
const width = containerRef.current.offsetWidth;
const height = containerRef.current.offsetHeight;
// 更新相机图
const camera = cameraRef.current;
camera.left = width / -2;
camera.right = width / 2;
camera.top = height / 2;
camera.bottom = height / -2;
camera.updateProjectionMatrix();
// 更新渲染器大小
rendererRef.current.setSize(width, height);
// 只在尺寸显著变化时重新生成粒子
const currentSize = Math.min(width, height);
const previousSize = sceneRef.current.userData.previousSize || currentSize;
const sizeChange = Math.abs(currentSize - previousSize) / previousSize;
if (sizeChange > 0.2 && src === "") {
if (sceneRef.current) {
cleanupResources(sceneRef.current);
}
sceneRef.current.userData.previousSize = currentSize;
updateParticles(width, height, ""); // 传入空字符串作为 instanceId
}
}, [src, updateParticles]);
// 主要的 useEffect
useEffect(() => {
isMountedRef.current = true;
if (!containerRef.current) return;
const width = containerRef.current.offsetWidth;
const height = containerRef.current.offsetHeight;
const scene = new THREE.Scene();
sceneRef.current = scene;
const camera = new THREE.OrthographicCamera(
width / -2,
width / 2,
height / 2,
height / -2,
1,
1000,
);
camera.position.z = 500;
camera.lookAt(new THREE.Vector3(0, 0, 0));
cameraRef.current = camera;
const renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: window.innerWidth > 768,
powerPreference: "low-power",
failIfMajorPerformanceCaveat: false,
canvas: document.createElement("canvas"),
});
// 在初始化渲染后立即添加错误检查
if (!renderer.capabilities.isWebGL2) {
console.warn("WebGL2 not supported, falling back...");
renderer.dispose();
renderer.forceContextLoss();
return;
}
renderer.setPixelRatio(
Math.min(window.devicePixelRatio, window.innerWidth <= 768 ? 2 : 3),
);
renderer.setSize(width, height);
rendererRef.current = renderer;
// 修改渲染器添加到DOM的部分
if (containerRef.current && isMountedRef.current && renderer.domElement) {
try {
containerRef.current.appendChild(renderer.domElement);
} catch (e) {
console.warn("Failed to append renderer:", e);
return;
}
}
// 检查是否应该显示笑脸
if (src === "") {
const { particles, positionArray, colorArray, particleSize } =
createSmileParticles(width, height);
const material = new THREE.PointsMaterial({
size: particleSize,
vertexColors: true,
transparent: true,
opacity: 1,
sizeAttenuation: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
depthTest: false,
});
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(positionArray, 3),
);
geometry.setAttribute(
"color",
new THREE.Float32BufferAttribute(colorArray, 3),
);
const points = new THREE.Points(geometry, material);
scene.add(points);
// 添加这一行来获取position属性
const positionAttribute = geometry.attributes
.position as THREE.BufferAttribute;
// 修改动画效果,添加完成回调
particles.forEach((particle, i) => {
const i3 = i * 3;
const distanceToCenter = Math.sqrt(
Math.pow(particle.originalX, 2) + Math.pow(particle.originalY, 2),
);
const maxDistance = Math.sqrt(
Math.pow(width / 2, 2) + Math.pow(height / 2, 2),
);
const normalizedDistance = distanceToCenter / maxDistance;
gsap.to(positionAttribute.array, {
duration: 0.8,
delay: normalizedDistance * 0.6,
[i3]: particle.originalX,
[i3 + 1]: particle.originalY,
[i3 + 2]: 0,
ease: "sine.inOut",
onUpdate: () => {
positionAttribute.needsUpdate = true;
},
onComplete: () => {
// 动画完成后设置标记,防止被清理
if (scene) {
scene.userData.isSmileComplete = true;
}
},
});
});
// 启动动画循环
const animate = () => {
if (renderer && scene && camera) {
renderer.render(scene, camera);
}
animationFrameRef.current = requestAnimationFrame(animate);
};
animate();
// 设置 resize 监听
const throttledResize = throttle(handleResize, 200, {
leading: true,
trailing: true,
});
const resizeObserver = new ResizeObserver(throttledResize);
resizeObserver.observe(containerRef.current);
window.addEventListener("resize", throttledResize);
return () => {
throttledResize.cancel();
if (containerRef.current) {
resizeObserver.unobserve(containerRef.current);
}
window.removeEventListener("resize", throttledResize);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
if (rendererRef.current && containerRef.current) {
containerRef.current.removeChild(rendererRef.current.domElement);
rendererRef.current.dispose();
}
gsap.killTweensOf("*");
};
}
// 建错误函数
const showErrorAnimation = () => {
if (!scene) return;
const { particles, positionArray, colorArray, particleSize } =
createErrorParticles(width, height);
const material = new THREE.PointsMaterial({
size: particleSize,
vertexColors: true,
transparent: true,
opacity: 1,
sizeAttenuation: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
depthTest: false,
});
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(positionArray, 3),
);
geometry.setAttribute(
"color",
new THREE.Float32BufferAttribute(colorArray, 3),
);
const points = new THREE.Points(geometry, material);
scene.clear();
scene.add(points);
const positionAttribute = geometry.attributes.position;
particles.forEach((particle, i) => {
const i3 = i * 3;
gsap.to(positionAttribute.array, {
duration: 0.6,
delay: Math.random() * 0.2,
[i3]: particle.originalX,
[i3 + 1]: particle.originalY,
[i3 + 2]: 0,
ease: "back.out(1.7)",
onUpdate: () => {
positionAttribute.needsUpdate = true;
},
onComplete: () => {
// 添加标记表示错误动画已完成
if (scene) {
scene.userData.isErrorComplete = true;
}
},
});
});
};
// 加载
const img = new Image();
img.crossOrigin = "anonymous";
const timeoutId = setTimeout(() => {
showErrorAnimation();
}, 5000); // 5秒超时
img.onload = () => {
clearTimeout(timeoutId);
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (ctx) {
// 增加一个小的边距以确保盖
const padding = 2; // 添加2像素的距
canvas.width = width + padding * 2;
canvas.height = height + padding * 2;
const targetAspect = width / height;
const imgAspect = img.width / img.height;
let sourceWidth = img.width;
let sourceHeight = img.height;
let sourceX = 0;
let sourceY = 0;
if (imgAspect > targetAspect) {
sourceWidth = img.height * targetAspect;
sourceX = (img.width - sourceWidth) / 2;
} else {
sourceHeight = img.width / targetAspect;
sourceY = (img.height - sourceHeight) / 2;
}
// 绘制时考虑padding
ctx.drawImage(
img,
sourceX,
sourceY,
sourceWidth,
sourceHeight,
padding,
padding,
width,
height,
);
// 采样时也要考虑padding
const imageData = ctx.getImageData(padding, padding, width, height);
const particles: Particle[] = [];
const positionArray = [];
const colorArray = [];
const samplingGap = Math.ceil(Math.max(width, height) / 80);
// 采样裁剪的图片像素
for (let y = 0; y < height; y += samplingGap) {
for (let x = 0; x < width; x += samplingGap) {
const i = (y * width + x) * 4;
const r = imageData.data[i] / 255;
const g = imageData.data[i + 1] / 255;
const b = imageData.data[i + 2] / 255;
const a = imageData.data[i + 3] / 255;
if (a > 0.3) {
const distanceToCenter = Math.sqrt(
Math.pow(x - width / 2, 2) + Math.pow(y - height / 2, 2),
);
const maxDistance = Math.sqrt(
Math.pow(width / 2, 2) + Math.pow(height / 2, 2),
);
const normalizedDistance = distanceToCenter / maxDistance;
const px = x - width / 2;
const py = height / 2 - y;
particles.push({
x: px,
y: py,
z: 0,
originalX: px,
originalY: py,
originalColor: new THREE.Color(r, g, b),
delay: normalizedDistance * 0.3,
});
// 机初始位置(根据距离调范围)
const spread = 1 - normalizedDistance * 0.5; // 距离越远,扩散越小
positionArray.push(
(Math.random() - 0.5) * width * spread,
(Math.random() - 0.5) * height * spread,
(Math.random() - 0.5) * 50 * spread,
);
colorArray.push(r, g, b);
}
}
}
const size = Math.min(width, height);
const scale = size / 200;
const particleSize = Math.max(1.2, scale * 1.2);
const geometry = new THREE.BufferGeometry();
const material = new THREE.PointsMaterial({
size: particleSize,
vertexColors: true,
transparent: true,
opacity: 1,
sizeAttenuation: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
depthTest: false,
});
scene.clear();
geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(positionArray, 3),
);
geometry.setAttribute(
"color",
new THREE.Float32BufferAttribute(colorArray, 3),
);
const points = new THREE.Points(geometry, material);
scene.add(points);
// 画
const positionAttribute = geometry.attributes.position;
const colorAttribute = geometry.attributes.color;
let completedAnimations = 0;
const totalAnimations = particles.length * 2; // 置和颜色动画
const checkComplete = () => {
completedAnimations++;
if (completedAnimations === totalAnimations) {
onLoad?.();
onAnimationComplete?.();
}
};
particles.forEach((particle, i) => {
const i3 = i * 3;
// 位置动画
gsap.to(positionAttribute.array, {
duration: 1.2 + Math.random() * 0.3,
delay: particle.delay,
[i3]: particle.originalX,
[i3 + 1]: particle.originalY,
[i3 + 2]: 0,
ease: customEase,
onUpdate: () => {
positionAttribute.needsUpdate = true;
},
onComplete: checkComplete,
});
// 色动画
gsap.to(colorAttribute.array, {
duration: 1,
delay: particle.delay + 0.2,
[i3]: particle.originalColor.r,
[i3 + 1]: particle.originalColor.g,
[i3 + 2]: particle.originalColor.b,
ease: "power2.inOut",
onUpdate: () => {
colorAttribute.needsUpdate = true;
},
onComplete: checkComplete,
});
});
// 修改动画序列部分
const timeline = gsap.timeline({
defaults: { ease: "power2.inOut" },
});
const imgElement = document.querySelector(
`img[src="${src}"]`,
) as HTMLImageElement;
if (imgElement) {
// 设置初始状态
gsap.set(imgElement, { opacity: 0 });
timeline
.to(imgElement, {
opacity: 1,
duration: 0.8,
delay: 1.6,
})
.to(
material,
{
opacity: 0,
duration: 0.8,
},
"-=0.6",
); // 前开始消失
}
return { particles, positionArray, colorArray, particleSize };
}
};
img.src = src || "";
// 画循环
const animate = () => {
if (renderer && scene && camera) {
renderer.render(scene, camera);
}
animationFrameRef.current = requestAnimationFrame(animate);
};
animate();
// 添加 resize 监听
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(containerRef.current);
// 添加窗口 resize 监听
window.addEventListener("resize", handleResize);
return () => {
clearTimeout(timeoutId);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
if (renderer && containerRef.current) {
containerRef.current.removeChild(renderer.domElement);
renderer.dispose();
}
// 清有 GSAP 画
gsap.killTweensOf("*");
// 移除 resize 监听
if (containerRef.current) {
resizeObserver.unobserve(containerRef.current);
}
window.removeEventListener("resize", handleResize);
};
}, [cleanup, src, handleResize, onLoad, onAnimationComplete]);
return <div ref={containerRef} className="w-full h-full" />;
};
let instanceCounter = 0;
// 添加性能检测函数
const detectDevicePerformance = () => {
// 检查是否在浏览器环境
if (typeof window === "undefined") {
return 1; // 服务器端返回默认值
}
try {
// 检查是否支持 hardwareConcurrency
const cores = navigator?.hardwareConcurrency || 2;
// 检查设备内 (如果支持)
const memory = (navigator as any)?.deviceMemory || 4;
// 检查是否为移动设备
const isMobile =
typeof navigator !== "undefined"
? /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent,
)
: false;
// 基于性能指标计算并发数
let concurrent = 1; // 默认值
if (!isMobile) {
if (cores >= 8 && memory >= 8) {
concurrent = 4; // 高性能设备
} else if (cores >= 4 && memory >= 4) {
concurrent = 3; // 中等性能设备
} else {
concurrent = 2; // 较低性能设备
}
}
return concurrent;
} catch (error) {
console.warn("性能检测失败,使用默认值:", error);
return 1; // 出错时返回最保守的值
}
};
// 修改队列管理
const loadingQueue = {
items: new Map<
string,
{
isProcessing: boolean;
instanceId: string;
isLongConnection: boolean;
lastActiveTime?: number;
lastProcessState?: boolean;
lastLogTime?: number; // 添加这个字段来控制日志输出频率
}
>(),
pendingQueue: new Set<string>(), // 备选队列,存储已加载完成的长连接
maxConcurrent: 1,
currentProcessing: 0,
get availableSlots() {
// 只计算普通请求的槽位,排除错误和长连接
const normalProcessing = Array.from(this.items.values()).filter(
(item) => item.isProcessing && !item.isLongConnection,
).length;
return this.maxConcurrent - normalProcessing;
},
add(url: string, instanceId: string, isLongConnection = false) {
const key = `${instanceId}:${url}`;
if (!this.items.has(key)) {
this.items.set(key, {
isProcessing: false,
instanceId,
isLongConnection,
lastActiveTime: Date.now(),
});
// 连接<E8BF9E><E68EA5>再直接进入备选队列而是等待加载完成后再加入
this.processQueue();
return true;
}
return false;
},
processQueue() {
if (this.availableSlots > 0) {
let nextKey: string | undefined;
// 修改这部分逻辑,不再区分长连接和普通请求
const nextItem = Array.from(this.items.entries()).find(
([_, item]) => !item.isProcessing,
);
if (nextItem) {
nextKey = nextItem[0];
}
if (nextKey) {
this.startProcessing(nextKey);
}
}
},
startProcessing(key: string) {
const item = this.items.get(key);
if (item && !item.isProcessing) {
item.isProcessing = true;
// 只有普通请求且不是错误状态时才增加处理数量
if (!item.isLongConnection && !key.includes("error")) {
this.currentProcessing++;
}
}
},
// 添加新方法:将长连接添加到备选队列
addToPending(url: string, instanceId: string) {
const key = `${instanceId}:${url}`;
const item = this.items.get(key);
if (item?.isLongConnection) {
this.pendingQueue.add(key);
}
},
remove(url: string, instanceId: string) {
const key = `${instanceId}:${url}`;
const item = this.items.get(key);
// 只有普通请求且正在处理时才减少处理数量
if (
item?.isProcessing &&
!item.isLongConnection &&
!key.includes("error")
) {
this.currentProcessing--;
}
// 确保从队列中移除
this.items.delete(key);
this.pendingQueue.delete(key);
// 如果是错误状态,立即处理下一个请求
if (key.includes("error")) {
this.processQueue();
} else {
// 使用 requestAnimationFrame 来确保状态更新后再处理队列
requestAnimationFrame(() => {
this.processQueue();
});
}
},
canProcess(url: string, instanceId: string): boolean {
const key = `${instanceId}:${url}`;
const item = this.items.get(key);
// 错误状态的处理保持不变
if (key.includes("error")) {
if (!item?.lastLogTime) {
if (item) {
item.lastLogTime = Date.now();
}
}
return true;
}
// 移除长连接的特殊处理,统一处理所有请求
const canProcess = item?.isProcessing || false;
if (
item &&
(item.lastProcessState !== canProcess ||
!item.lastLogTime ||
Date.now() - item.lastLogTime > 1000)
) {
item.lastProcessState = canProcess;
item.lastLogTime = Date.now();
}
return canProcess;
},
};
// 在组件挂载时更新 maxConcurrent
if (typeof window !== "undefined") {
loadingQueue.maxConcurrent = detectDevicePerformance();
}
export const ImageLoader = ({
src,
alt,
className,
}: {
src?: string;
alt: string;
className: string;
}) => {
// 为每个实例创唯一ID
const instanceId = useRef(`img-${++instanceCounter}`);
// 保持现有的状态和引用
const [status, setStatus] = useState<LoaderStatus>({
isLoading: true,
hasError: false,
timeoutError: false,
});
const [showImage, setShowImage] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout>();
const loadingRef = useRef(false);
const imageRef = useRef<HTMLImageElement | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [animationComplete, setAnimationComplete] = useState(false);
// 把 useEffect 移到这里
useEffect(() => {
if (!src) return;
setShowImage(false);
setAnimationComplete(false);
setCanShowParticles(false);
loadingQueue.add(src, instanceId.current);
const checkQueue = () => {
if (loadingQueue.canProcess(src, instanceId.current)) {
const now = Date.now();
const timeSinceLastAnimation = now - lastAnimationTime;
if (particleLoadQueue.size === 0) {
particleLoadQueue.add(src);
setCanShowParticles(true);
lastAnimationTime = now;
return;
}
const delay = Math.max(
MIN_DELAY,
Math.min(ANIMATION_THRESHOLD, timeSinceLastAnimation),
);
const timer = setTimeout(() => {
const key = `${instanceId.current}:${src}`;
if (!loadingQueue.items.has(key)) return;
particleLoadQueue.add(src);
setCanShowParticles(true);
lastAnimationTime = Date.now();
}, delay);
return () => {
clearTimeout(timer);
particleLoadQueue.delete(src);
};
}
const timer = setTimeout(checkQueue, 100);
return () => clearTimeout(timer);
};
const cleanup = checkQueue();
return () => {
cleanup?.();
loadingQueue.remove(src, instanceId.current);
};
}, [src]);
// 处理图片加载
const preloadImage = useCallback(() => {
if (!src || loadingRef.current) return;
loadingRef.current = true;
// 清理之前的资源
if (imageRef.current) {
imageRef.current.src = "";
imageRef.current = null;
}
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
setStatus({
isLoading: true,
hasError: false,
timeoutError: false,
});
setShowImage(false);
const img = new Image();
img.crossOrigin = "anonymous";
timeoutRef.current = setTimeout(() => {
loadingRef.current = false;
setStatus({
isLoading: false,
hasError: true,
timeoutError: true,
});
// 超时时触发错误动画
setCanShowParticles(true);
if (src) {
loadingQueue.remove(src, instanceId.current);
particleLoadQueue.delete(src);
}
}, 5000);
img.onload = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// 在图片加载成功后,立即创建缓存一个适应容器大小的图片
if (containerRef.current) {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (ctx) {
const containerWidth = containerRef.current.offsetWidth;
const containerHeight = containerRef.current.offsetHeight;
canvas.width = containerWidth;
canvas.height = containerHeight;
// 保持比例绘制图片
const targetAspect = containerWidth / containerHeight;
const imgAspect = img.width / img.height;
let sourceWidth = img.width;
let sourceHeight = img.height;
let sourceX = 0;
let sourceY = 0;
if (imgAspect > targetAspect) {
sourceWidth = img.height * targetAspect;
sourceX = (img.width - sourceWidth) / 2;
} else {
sourceHeight = img.width / targetAspect;
sourceY = (img.height - sourceHeight) / 2;
}
ctx.drawImage(
img,
sourceX,
sourceY,
sourceWidth,
sourceHeight,
0,
0,
containerWidth,
containerHeight,
);
// 创建新的图片对象使用调整后的canvas数据
const adjustedImage = new Image();
adjustedImage.src = canvas.toDataURL();
imageRef.current = adjustedImage;
}
} else {
imageRef.current = img;
}
// 如果是长连接,加载成功后添加到备选队列
if (src && src === src) {
// 相同URL判断
loadingQueue.addToPending(src, instanceId.current);
}
loadingRef.current = false;
setStatus({
isLoading: false,
hasError: false,
timeoutError: false,
});
};
img.onerror = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
loadingRef.current = false;
setStatus({
isLoading: false,
hasError: true,
timeoutError: false,
});
// 错误时立即触发错误动画
setCanShowParticles(true);
if (src) {
loadingQueue.remove(src, instanceId.current);
particleLoadQueue.delete(src);
}
};
if (src) {
img.src = src;
}
}, [src]);
useEffect(() => {
preloadImage();
return () => {
loadingRef.current = false;
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [src, preloadImage]);
// 添加一个<E4B880><E4B8AA><EFBFBD>的状来控制粒子动画
const [canShowParticles, setCanShowParticles] = useState(false);
// 添加加载动画组件
const LoadingSpinner = () => (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-[--accent-9] border-t-transparent rounded-full animate-spin" />
</div>
);
return (
<div className="relative w-full h-full overflow-hidden">
<div
className={`absolute inset-0 ${BG_CONFIG.className} rounded-lg overflow-hidden`}
>
{src && (status.isLoading || !canShowParticles) && <LoadingSpinner />}
{(!src || (src && !animationComplete && canShowParticles)) && (
<ParticleImage
src={src}
status={status}
onLoad={() => {
if (imageRef.current) {
// 保持为空
}
}}
onAnimationComplete={() => {
if (imageRef.current && src) {
setShowImage(true);
requestAnimationFrame(() => {
setAnimationComplete(true);
particleLoadQueue.delete(src);
loadingQueue.remove(src, instanceId.current);
setTimeout(() => {
const img = document.querySelector(
`img[src="${imageRef.current?.src}"]`,
) as HTMLImageElement;
if (img) {
img.style.opacity = "1";
}
}, 50);
});
}
}}
/>
)}
</div>
{/* 保持现图片渲染部分 */}
{!status.hasError && !status.timeoutError && imageRef.current && (
<div className="absolute inset-0 rounded-lg overflow-hidden">
<img
src={imageRef.current.src}
alt={alt}
className={`w-full h-full object-cover ${className}`}
style={{
opacity: 0,
visibility: showImage ? "visible" : "hidden",
objectFit: "cover",
objectPosition: "center",
willChange: "opacity, transform",
transition: "opacity 0.5s ease-in-out",
transform: "scale(1.015)",
transformOrigin: "center bottom",
}}
/>
</div>
)}
</div>
);
};