// ImageAnimation.tsx
'use client'
import { useEffect, useRef } from 'react'
import * as THREE from 'three'
import { gsap } from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { FontLoader } from 'three/addons/loaders/FontLoader.js'
import { TextGeometry } from 'three/addons/geometries/TextGeometry.js'
interface AnimatedImagesProps {
images: string[]
texts: string[]
SecondTexts: string[]
SecondTargetPositions: [number, number, number][]
targetPositions: [number, number, number][]
}
gsap.registerPlugin(ScrollTrigger)
const AnimatedImages: React.FC<AnimatedImagesProps> = ({ images, SecondTexts,targetPositions,texts ,SecondTargetPositions}) => {
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!containerRef.current) return
// 创建场景
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
)
// camera.position.set(0, -10, 20)
// camera.lookAt(0, 0, 0)
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
renderer.setSize(window.innerWidth, window.innerHeight)
containerRef.current.appendChild(renderer.domElement)
// 计算响应式尺寸
const calculateImageSize = () => {
const aspectRatio = window.innerWidth / window.innerHeight
let imageWidth, imageHeight
if (window.innerWidth <= 768) {
// 移动设备
imageWidth = 1.5
imageHeight = 1.5
} else if (window.innerWidth <= 1024) {
// 平板设备
imageWidth = 2
imageHeight = 2
} else {
// 桌面设备
imageWidth = 2.5
imageHeight = 2.5
}
return { width: imageWidth, height: imageHeight }
}
// 创建图片平面 32-46
const imageGroups: THREE.Group[] = []
const textureLoader = new THREE.TextureLoader()
const { width: initialWidth, height: initialHeight } = calculateImageSize()
images.forEach((imageUrl, index) => {
const texture = textureLoader.load(imageUrl)
const geometry = new THREE.PlaneGeometry(initialWidth, initialHeight)
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: 0.8, // 设置初始透明度为0
})
const mesh = new THREE.Mesh(geometry, material)
const group = new THREE.Group()
group.add(mesh)
// 计算屏幕底部的位置
const screenBottomY = -10
// 设置初始位置在屏幕正下方
group.position.set(0, screenBottomY, 0)
scene.add(group)
imageGroups.push(group)
})
// 调整相机位置以适应不同屏幕
const updateCameraPosition = () => {
const aspectRatio = window.innerWidth / window.innerHeight
if (window.innerWidth <= 768) {
camera.position.z = 8
} else if (window.innerWidth <= 1024) {
camera.position.z = 7
} else {
camera.position.z = 5
}
camera.aspect = aspectRatio
camera.updateProjectionMatrix()
}
updateCameraPosition()
const addLights = () => {
// 环境光
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5)
scene.add(ambientLight)
// 方向光
const directionalLight = new THREE.DirectionalLight(0xffffff, 1)
directionalLight.position.set(5, 5, 5)
scene.add(directionalLight)
// 点光源(可选)
const pointLight = new THREE.PointLight(0xffffff, 0.5)
pointLight.position.set(0, 0, 5)
scene.add(pointLight)
}
// 在场景初始化时调用
addLights()
// 处理窗口大小变化
const handleResize = () => {
const { width: newWidth, height: newHeight } = calculateImageSize()
// 更新渲染器大小
renderer.setSize(window.innerWidth, window.innerHeight)
// 更新相机
camera.aspect = window.innerWidth / window.innerHeight
camera.updateProjectionMatrix()
updateCameraPosition()
// 更新所有图片的大小
imageGroups.forEach(group => {
const mesh = group.children[0] as THREE.Mesh
const newGeometry = new THREE.PlaneGeometry(newWidth, newHeight)
mesh.geometry.dispose()
mesh.geometry = newGeometry
})
// 更新图片位置
imageGroups.forEach((group, index) => {
const targetPos = targetPositions[index]
// 根据屏幕大小调整位置
const scaleFactor = window.innerWidth <= 768 ? 0.7 : 1
group.position.set(
targetPos[0] * scaleFactor,
targetPos[1] * scaleFactor,
targetPos[2]
)
})
}
window.addEventListener('resize', handleResize)
// 创建动画
let animationsCompleted = 0
imageGroups.forEach((group, index) => {
const targetPos = targetPositions[index]
const scaleFactor = window.innerWidth <= 768 ? 0.7 : 1
// 初始动画
gsap.to(group.position, {
x: targetPos[0] * scaleFactor,
y: targetPos[1] * scaleFactor,
z: targetPos[2],
duration: 1.5,
delay: index * 0.2,
ease: 'power2.out',
onComplete: () => {
animationsCompleted++
if (animationsCompleted === imageGroups.length) {
const textMeshes: THREE.Mesh[] = []
const fontLoader = new FontLoader()
fontLoader.load('https://threejs.org/examples/fonts/helvetiker_regular.typeface.json', (font) => {
//第一组文字
texts.forEach((text, index) => {
const textGeometry = new TextGeometry(text, {
font: font,
size: 0.5,
depth: 0.2,
})
// 或者使用十六进制颜色字符串
const textColors = [
'#ff0000', // 红色
'#00ff00', // 绿色
'#0000ff', // 蓝色
'#ffff00', // 黄色
'#ff00ff', // 粉色
'#00ffff', // 青色
'#ffa500', // 橙色
'#800080' // 紫色
]
const textMaterial = new THREE.MeshStandardMaterial({
color: new THREE.Color(textColors[index] || '
transparent: true, // 确保添加这个属性以支持透明度动画
metalness: 0.5, // 金属感
roughness: 0.5, // 粗糙度
emissive: new THREE.Color(textColors[index] || '
})
const textMesh = new THREE.Mesh(textGeometry, textMaterial)
// 居中文字
textGeometry.computeBoundingBox()
if (textGeometry.boundingBox) {
const centerOffset = -0.5 * (textGeometry.boundingBox.max.x - textGeometry.boundingBox.min.x)
textMesh.position.x = centerOffset
}
// 初始位置在底部,每行错开位置
textMesh.position.y = -5
scene.add(textMesh)
textMeshes.push(textMesh)
gsap.to(textMesh.position, {
y: -1 + index * 1, // 最终位置,每行错开1个单位
duration: 1.5,
delay: index * 0.3, // 错开动画开始时间
ease: "power2.out",
onComplete: () => {
// 滚动时向上移动并移出
gsap.to(textMesh.position, {
y: '+=15', // 向上移动10个单位
scrollTrigger: {
trigger: containerRef.current,
start: "top top", // 当容器顶部碰到视窗顶部时开始
end: "20%", // 滚动1000px后结束
scrub: 1, // 平滑滚动
// markers: true, // 调试用,可以看到触发位置
}
})
// 同时控制透明度
gsap.to(textMesh.material, {
opacity: 0,
scrollTrigger: {
trigger: containerRef.current,
start: "top top",
end: "20%",
scrub: 1,
}
})
}
})
})
// 第二组文字
const secondTextMeshes: THREE.Mesh[] = []
SecondTexts.forEach((text, index) => {
const textGeometry = new TextGeometry(text, {
font: font,
size: 0.5,
depth: 0.2,
})
const textMaterial = new THREE.MeshPhongMaterial({
color: 0xffffff,
transparent: true,
opacity: 0, // 初始设置为透明
})
const textMesh = new THREE.Mesh(textGeometry, textMaterial)
// 居中文字
textGeometry.computeBoundingBox()
if (textGeometry.boundingBox) {
const centerOffset = -0.5 * (textGeometry.boundingBox.max.x - textGeometry.boundingBox.min.x)
textMesh.position.x = centerOffset
}
// 初始位置设置在更低的位置
textMesh.position.y = -15
scene.add(textMesh)
secondTextMeshes.push(textMesh)
// 第二组文字的动画,跟随滚动从底部进入
// 创建位置动画
gsap.fromTo(textMesh.position,
{
y: -15 // 起始位置
},
{
y: -1 + index * 1, // 目标位置
scrollTrigger: {
trigger: containerRef.current,
start: "30% top ", // 修改触发位置
end: "40%",
scrub: 1,
// markers: true, // 调试用
toggleActions: "play none none reverse"
}
}
)
// 同时控制透明度
// 创建独立的透明度动画
gsap.fromTo(textMaterial,
{
opacity: 0
},
{
opacity: 1,
scrollTrigger: {
trigger: containerRef.current,
start: "40% top", // 修改触发位置
end: "60%",
scrub: 1,
toggleActions: "play none none reverse"
}
}
)
})
})
// 鼠标滚动图片动画
imageGroups.forEach((group, i) => {
const startPos = targetPositions[i]
const endPos = SecondTargetPositions[i]
gsap.fromTo(group.position,
{
x: startPos[0],
y: startPos[1],
z: startPos[2]
},
{
x: endPos[0],
y: endPos[1],
z: endPos[2],
scrollTrigger: {
trigger: containerRef.current,
start: "top top",
end: "+=1000", // 滚动1000px后结束
scrub: 1, // 平滑过渡
markers: true, // 调试用
toggleActions: "play none none reverse"
}
}
)
})
}
}
})
})
// 动画循环
const animate = () => {
requestAnimationFrame(animate)
renderer.render(scene, camera)
}
animate()
// 清理函数
return () => {
window.removeEventListener('resize', handleResize)
containerRef.current?.removeChild(renderer.domElement)
scene.clear()
imageGroups.forEach(group => {
const mesh = group.children[0] as THREE.Mesh
mesh.geometry.dispose()
(mesh.material as THREE.Material).dispose()
})
}
}, [images, targetPositions,texts,SecondTargetPositions,SecondTexts])
return <div ref={containerRef} style={{ width: '100%', height: '100vh' }} />
}
export default AnimatedImages
import AnimatedImages from '../components/ImageAnimation';
export default function Home() {
const images = [
'/img0.jpg',
'/img1.jpg',
'/img2.jpg',
'/img3.jpg',
'/img4.jpg',
'/img5.jpg',
'/img6.jpg',
'/img7.jpg',
];
const targetPositions: [number, number, number][] = [
[-5, 3, 0],
[0, 2, 0],
[5, 3, 0],
[-7, 0, 0],
[1, 1, 0],
[-2, -1, 0],
[0, -1, 0],
[4, -2.5, 0],
];
const SecondTargetPositions: [number, number, number][] = [
[0, 2, 0],
[1, 1, 0],
[0, -1, 0],
[-2, 1, 0],
[1, -1, 0],
[-1, 1, 0],
[1, -1, 0],
[3, -1.5, 0],
];
const texts = [
'threejs----1',
'world',
'hello',
];
const SecondTexts = [
'threejs',
'world',
'hello'
];
return (
<div>
<AnimatedImages
images={images}
targetPositions={targetPositions}
SecondTargetPositions={SecondTargetPositions}
texts={texts}
SecondTexts={SecondTexts}
/>
</div>
);
}
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
const updateCameraPosition = () => {
if (window.innerWidth <= 768) {
camera.position.z = 8;
} else if (window.innerWidth <= 1024) {
camera.position.z = 7;
} else {
camera.position.z = 5;
}
};
const targetPositions: [number, number, number][] = [
[-8, 3, 0],
[-4, 3, 0],
[0, 3, 0],
[4, 3, 0],
[8, 3, 0],
[-6, -3, 0],
[0, -3, 0],
[6, -3, 0],
];
const SecondTargetPositions: [number, number, number][] = [
[-6, 2, 0],
[-3, 2, 0],
[0, 2, 0],
[3, 2, 0],
[6, 2, 0],
[-4, -2, 0],
[0, -2, 0],
[4, -2, 0],
];
-8 到 8 之间:
- 负值:向左偏移
- 正值:向右偏移
- 0:屏幕中心
推荐区间:
- 外侧元素:±6 到 ±8
- 中间元素:±2 到 ±4
- 中心元素:0
-4 到 4 之间:
- 负值:向下偏移
- 正值:向上偏移
- 0:屏幕中心
推荐区间:
- 上方元素:2 到 4
- 中间元素:-1 到 1
- 下方元素:-2 到 -4
通常保持为 0
- 负值:向屏幕内
- 正值:向屏幕外
- 0:默认平面
特殊效果可用范围:-2 到 2