239 lines
5.8 KiB
Vue
239 lines
5.8 KiB
Vue
<script setup>
|
|
import * as THREE from 'three'
|
|
import { onMounted, onUnmounted, ref } from 'vue'
|
|
import { useBreakpoints } from '@vueuse/core'
|
|
|
|
const canvasRef = ref(null)
|
|
|
|
// 响应式控制粒子数量
|
|
const breakpoints = useBreakpoints({
|
|
mobile: 640,
|
|
tablet: 1024
|
|
})
|
|
|
|
const isMobile = breakpoints.smaller('tablet')
|
|
const particleCount = isMobile.value ? 800 : 1500
|
|
|
|
// 动画参数
|
|
const config = {
|
|
particleSize: 1.5,
|
|
systemRadius: 15,
|
|
baseSpeed: 0.2,
|
|
hoverRadius: 3,
|
|
color: 0x7ac5e8,
|
|
lineColor: 0x5ab0d6,
|
|
lineDistance: 2.5
|
|
}
|
|
|
|
let scene, camera, renderer, particles, lines, mouse = { x: 0, y: 0 }
|
|
|
|
const initThreeJS = () => {
|
|
// 1. 初始化场景
|
|
scene = new THREE.Scene()
|
|
scene.background = new THREE.Color(0x000000)
|
|
scene.fog = new THREE.FogExp2(0x000000, 0.001)
|
|
|
|
// 2. 初始化相机
|
|
camera = new THREE.PerspectiveCamera(
|
|
75,
|
|
window.innerWidth / window.innerHeight,
|
|
0.1,
|
|
1000
|
|
)
|
|
camera.position.z = 25
|
|
|
|
// 3. 初始化渲染器
|
|
renderer = new THREE.WebGLRenderer({
|
|
canvas: canvasRef.value,
|
|
antialias: true,
|
|
alpha: true
|
|
})
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
|
renderer.setSize(window.innerWidth, window.innerHeight)
|
|
|
|
// 4. 创建粒子系统
|
|
createParticles()
|
|
|
|
// 5. 动画循环
|
|
animate()
|
|
}
|
|
|
|
const createParticles = () => {
|
|
const geometry = new THREE.BufferGeometry()
|
|
const positions = new Float32Array(particleCount * 3)
|
|
const sizes = new Float32Array(particleCount)
|
|
|
|
// 初始化粒子位置
|
|
for (let i = 0; i < particleCount; i++) {
|
|
const i3 = i * 3
|
|
|
|
// 球体随机分布
|
|
const radius = config.systemRadius * Math.random()
|
|
const theta = Math.random() * Math.PI * 2
|
|
const phi = Math.acos(2 * Math.random() - 1)
|
|
|
|
positions[i3] = radius * Math.sin(phi) * Math.cos(theta)
|
|
positions[i3 + 1] = radius * Math.sin(phi) * Math.sin(theta)
|
|
positions[i3 + 2] = radius * Math.cos(phi)
|
|
|
|
sizes[i] = config.particleSize * (0.5 + Math.random() * 0.5)
|
|
}
|
|
|
|
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
|
|
geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1))
|
|
|
|
// 粒子材质
|
|
const material = new THREE.PointsMaterial({
|
|
color: config.color,
|
|
size: config.particleSize,
|
|
sizeAttenuation: true,
|
|
transparent: true,
|
|
opacity: 0.8,
|
|
blending: THREE.AdditiveBlending
|
|
})
|
|
|
|
particles = new THREE.Points(geometry, material)
|
|
scene.add(particles)
|
|
|
|
// 创建连接线
|
|
createConnections(positions)
|
|
}
|
|
|
|
const createConnections = (positions) => {
|
|
const lineGeometry = new THREE.BufferGeometry()
|
|
const linePositions = new Float32Array(particleCount * 3 * 2) // 每个粒子可能连接多个
|
|
|
|
// 简化的连接逻辑 (实际项目可以使用更高效的算法)
|
|
let lineIndex = 0
|
|
for (let i = 0; i < particleCount; i++) {
|
|
const i3 = i * 3
|
|
const p1 = new THREE.Vector3(
|
|
positions[i3],
|
|
positions[i3 + 1],
|
|
positions[i3 + 2]
|
|
)
|
|
|
|
// 只检查附近粒子
|
|
for (let j = i + 1; j < Math.min(i + 20, particleCount); j++) {
|
|
const j3 = j * 3
|
|
const p2 = new THREE.Vector3(
|
|
positions[j3],
|
|
positions[j3 + 1],
|
|
positions[j3 + 2]
|
|
)
|
|
|
|
if (p1.distanceTo(p2) < config.lineDistance) {
|
|
linePositions[lineIndex++] = p1.x
|
|
linePositions[lineIndex++] = p1.y
|
|
linePositions[lineIndex++] = p1.z
|
|
linePositions[lineIndex++] = p2.x
|
|
linePositions[lineIndex++] = p2.y
|
|
linePositions[lineIndex++] = p2.z
|
|
}
|
|
}
|
|
}
|
|
|
|
lineGeometry.setAttribute(
|
|
'position',
|
|
new THREE.BufferAttribute(linePositions, 3)
|
|
)
|
|
|
|
const lineMaterial = new THREE.LineBasicMaterial({
|
|
color: config.lineColor,
|
|
transparent: true,
|
|
opacity: 0.15,
|
|
blending: THREE.AdditiveBlending
|
|
})
|
|
|
|
lines = new THREE.LineSegments(lineGeometry, lineMaterial)
|
|
scene.add(lines)
|
|
}
|
|
|
|
const animate = () => {
|
|
requestAnimationFrame(animate)
|
|
|
|
// 粒子动画
|
|
const positions = particles.geometry.attributes.position.array
|
|
const time = Date.now() * 0.0001 * config.baseSpeed
|
|
|
|
for (let i = 0; i < particleCount; i++) {
|
|
const i3 = i * 3
|
|
|
|
// 添加噪声运动
|
|
positions[i3] += Math.sin(time + i) * 0.01
|
|
positions[i3 + 1] += Math.cos(time + i * 0.5) * 0.01
|
|
positions[i3 + 2] += Math.sin(time * 0.5 + i) * 0.01
|
|
|
|
// 鼠标交互效果
|
|
const dx = positions[i3] - mouse.x * 20
|
|
const dy = positions[i3 + 1] - mouse.y * 20
|
|
const distance = Math.sqrt(dx * dx + dy * dy)
|
|
|
|
if (distance < config.hoverRadius) {
|
|
positions[i3] += dx * 0.05
|
|
positions[i3 + 1] += dy * 0.05
|
|
}
|
|
}
|
|
|
|
particles.geometry.attributes.position.needsUpdate = true
|
|
renderer.render(scene, camera)
|
|
}
|
|
|
|
const handleResize = () => {
|
|
camera.aspect = window.innerWidth / window.innerHeight
|
|
camera.updateProjectionMatrix()
|
|
renderer.setSize(window.innerWidth, window.innerHeight)
|
|
}
|
|
|
|
const handleMouseMove = (event) => {
|
|
mouse.x = (event.clientX / window.innerWidth) * 2 - 1
|
|
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1
|
|
}
|
|
|
|
onMounted(() => {
|
|
initThreeJS()
|
|
window.addEventListener('resize', handleResize)
|
|
window.addEventListener('mousemove', handleMouseMove)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('resize', handleResize)
|
|
window.removeEventListener('mousemove', handleMouseMove)
|
|
if (renderer) renderer.dispose()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<canvas
|
|
ref="canvasRef"
|
|
class="particle-canvas"
|
|
aria-hidden="true"
|
|
/>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.particle-canvas {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
z-index: var(--z-index-particle);
|
|
pointer-events: none;
|
|
opacity: 0.6;
|
|
transition: opacity 0.3s ease;
|
|
}
|
|
|
|
/* 移动设备降低不透明度 */
|
|
@media (max-width: 768px) {
|
|
.particle-canvas {
|
|
opacity: 0.3;
|
|
}
|
|
}
|
|
|
|
/* 暗黑模式适配 */
|
|
[data-theme="dark"] .particle-canvas {
|
|
opacity: 0.4;
|
|
}
|
|
</style>
|