ToolKitToolKit工具箱
Loading effect…
查看源码 / Source点击展开
ParticleSphereVanilla.vue
<!--
  ParticleSphereVanilla.vue — 同样的效果,但不依赖 TresJS,只用原生 three.js

  迁移说明(Vite→Nuxt):
  - window.devicePixelRatio 加 SSG 守卫(组件虽由 <ClientOnly> 包裹, 仍防御性处理)
  - 本组件须在父级用 <ClientOnly> 包裹, 不在服务端渲染。
-->
<script setup lang="ts">
import { onMounted, onBeforeUnmount, useTemplateRef } from 'vue'
import * as THREE from 'three'
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js'
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js'
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js'

interface Props {
  count?: number
  radius?: number
  pointSize?: number
  colorA?: string
  colorB?: string
}
const { t: tr } = useI18n({ useScope: 'local' })

const props = withDefaults(defineProps<Props>(), {
  count: 16000,
  radius: 1.6,
  pointSize: 3.2,
  colorA: '#FFD166',
  colorB: '#EF476F',
})

const containerRef = useTemplateRef<HTMLDivElement>('container')

let renderer: THREE.WebGLRenderer
let composer: EffectComposer
let scene: THREE.Scene
let camera: THREE.PerspectiveCamera
let points: THREE.Points
let geometry: THREE.BufferGeometry
let material: THREE.ShaderMaterial
let coreRef: THREE.Mesh | null = null
let rafId = 0
let resizeObserver: ResizeObserver | null = null

const mouseTarget = new THREE.Vector3(0, 0, 1)
let mouseStrTarget = 0
const clock = new THREE.Clock()

const vertexShader = /* glsl */`
  uniform float uTime;
  uniform vec3  uMouse;
  uniform float uMouseStr;
  uniform float uPointSize;
  uniform float uPixelRatio;

  attribute float aSeed;
  varying float vDisp;
  varying float vPulse;

  float hash(vec3 p) { return fract(sin(dot(p, vec3(12.9898,78.233,37.719))) * 43758.5453); }
  float noise(vec3 p){
    vec3 i = floor(p), f = fract(p);
    f = f*f*(3.0-2.0*f);
    return mix(mix(mix(hash(i), hash(i+vec3(1,0,0)), f.x),
                   mix(hash(i+vec3(0,1,0)), hash(i+vec3(1,1,0)), f.x), f.y),
               mix(mix(hash(i+vec3(0,0,1)), hash(i+vec3(1,0,1)), f.x),
                   mix(hash(i+vec3(0,1,1)), hash(i+vec3(1,1,1)), f.x), f.y), f.z);
  }

  void main() {
    vec3 pos = position;
    vec3 dir = normalize(pos);

    float breathe = sin(uTime * 0.6 + aSeed * 6.28) * 0.04;
    float n = noise(dir * 2.5 + uTime * 0.25);
    float disp = (n - 0.5) * 0.35;

    float md = distance(dir, normalize(uMouse + vec3(0.0001)));
    float ripple = exp(-md * 4.0) * sin(md * 12.0 - uTime * 4.0) * uMouseStr * 0.5;

    // 周期性能量冲击波(从内部"轰"出来)
    float pulsePhase = fract(uTime * 0.25);
    float pulse = exp(-pulsePhase * 3.5) * sin(pulsePhase * 18.0) * 0.18;

    float total = breathe + disp + ripple + pulse;
    pos += dir * total;
    vDisp = total;
    vPulse = exp(-pulsePhase * 3.0);

    vec4 mv = modelViewMatrix * vec4(pos, 1.0);
    gl_Position = projectionMatrix * mv;
    // 屏幕像素粒子大小:基础 ~3px,远处衰减
    float atten = 5.0 / -mv.z;
    gl_PointSize = uPointSize * uPixelRatio * (0.9 + total * 1.4) * atten;
  }
`

const fragmentShader = /* glsl */`
  uniform vec3 uColorA;
  uniform vec3 uColorB;
  varying float vDisp;
  varying float vPulse;

  void main() {
    vec2 uv = gl_PointCoord - 0.5;
    float d = length(uv);
    if (d > 0.5) discard;
    float halo = smoothstep(0.5, 0.0, d);
    halo = pow(halo, 1.6);

    float t = smoothstep(-0.2, 0.3, vDisp);
    vec3 col = mix(uColorA, uColorB, t);
    // pulse 高峰时整体变白(像太阳爆发)
    col = mix(col, vec3(1.2, 0.95, 0.7), vPulse * 0.6);

    gl_FragColor = vec4(col * halo * 1.6, 1.0);
  }
`

function buildGeometry() {
  const g = new THREE.BufferGeometry()
  const positions = new Float32Array(props.count * 3)
  const seeds = new Float32Array(props.count)
  const phi = Math.PI * (Math.sqrt(5) - 1)
  for (let i = 0; i < props.count; i++) {
    const y = 1 - (i / (props.count - 1)) * 2
    const r = Math.sqrt(1 - y * y)
    const theta = phi * i
    positions[i * 3]     = Math.cos(theta) * r * props.radius
    positions[i * 3 + 1] = y * props.radius
    positions[i * 3 + 2] = Math.sin(theta) * r * props.radius
    seeds[i] = Math.random()
  }
  g.setAttribute('position', new THREE.BufferAttribute(positions, 3))
  g.setAttribute('aSeed', new THREE.BufferAttribute(seeds, 1))
  return g
}

function setSize(w: number, h: number) {
  renderer.setSize(w, h, false)
  composer.setSize(w, h)
  camera.aspect = w / h
  camera.updateProjectionMatrix()
}

function onPointerMove(e: PointerEvent) {
  const el = containerRef.value!
  const rect = el.getBoundingClientRect()
  const x = ((e.clientX - rect.left) / rect.width) * 2 - 1
  const y = -((e.clientY - rect.top) / rect.height) * 2 + 1
  mouseTarget.set(x, y, Math.sqrt(Math.max(0, 1 - x * x - y * y)))
  mouseStrTarget = 1
}
function onPointerLeave() {
  mouseStrTarget = 0
}

onMounted(() => {
  const el = containerRef.value!
  const w = el.clientWidth
  const h = el.clientHeight

  scene = new THREE.Scene()
  scene.background = new THREE.Color(0x100604)

  camera = new THREE.PerspectiveCamera(55, w / h, 0.1, 100)
  camera.position.set(0, 0, 5)

  renderer = new THREE.WebGLRenderer({ antialias: true })
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
  renderer.setSize(w, h)
  el.appendChild(renderer.domElement)

  // 中心小核 — 让 bloom 有个稳定亮源
  const coreGeom = new THREE.IcosahedronGeometry(0.18, 4)
  const coreMat = new THREE.MeshBasicMaterial({
    color: new THREE.Color('#FFE2A8'),
    transparent: true,
    opacity: 0.85,
    blending: THREE.AdditiveBlending,
    depthWrite: false,
  })
  const core = new THREE.Mesh(coreGeom, coreMat)
  coreRef = core
  scene.add(core)

  geometry = buildGeometry()
  material = new THREE.ShaderMaterial({
    uniforms: {
      uTime:       { value: 0 },
      uMouse:      { value: new THREE.Vector3(0, 0, 1) },
      uMouseStr:   { value: 0 },
      uPointSize:  { value: props.pointSize },
      uPixelRatio: { value: Math.min(window.devicePixelRatio, 2) },
      uColorA:     { value: new THREE.Color(props.colorA) },
      uColorB:     { value: new THREE.Color(props.colorB) },
    },
    vertexShader,
    fragmentShader,
    transparent: true,
    depthWrite: false,
    blending: THREE.AdditiveBlending,
  })
  points = new THREE.Points(geometry, material)
  scene.add(points)

  composer = new EffectComposer(renderer)
  composer.addPass(new RenderPass(scene, camera))
  // UnrealBloomPass(resolution, strength, radius, threshold) — 太阳风暴:强 bloom + 低阈值
  composer.addPass(new UnrealBloomPass(new THREE.Vector2(w, h), 1.6, 0.85, 0.25))

  const tick = () => {
    const t = clock.getElapsedTime()
    material.uniforms.uTime.value = t
    material.uniforms.uMouse.value.lerp(mouseTarget, 0.1)
    material.uniforms.uMouseStr.value += (mouseStrTarget - material.uniforms.uMouseStr.value) * 0.08
    points.rotation.y = t * 0.08
    points.rotation.x = Math.sin(t * 0.15) * 0.2
    // 内核同步脉冲:周期 4s,每次"轰"一下
    const phase = (t * 0.25) % 1
    const corePulse = Math.exp(-phase * 3.0)
    core.scale.setScalar(0.85 + corePulse * 0.5)
    coreMat.opacity = 0.55 + corePulse * 0.45
    composer.render()
    rafId = requestAnimationFrame(tick)
  }
  tick()

  resizeObserver = new ResizeObserver(entries => {
    for (const entry of entries) {
      const cr = entry.contentRect
      setSize(cr.width, cr.height)
    }
  })
  resizeObserver.observe(el)
})

onBeforeUnmount(() => {
  cancelAnimationFrame(rafId)
  resizeObserver?.disconnect()
  geometry?.dispose()
  material?.dispose()
  coreRef?.geometry.dispose()
  ;(coreRef?.material as THREE.Material | undefined)?.dispose()
  composer?.dispose()
  renderer?.dispose()
  if (renderer?.domElement?.parentNode) {
    renderer.domElement.parentNode.removeChild(renderer.domElement)
  }
})
</script>

<template>
  <div ref="container" class="ps-wrap" @pointermove="onPointerMove" @pointerleave="onPointerLeave">
    <div class="ps-hint">{{ tr('hint') }}</div>
  </div>
</template>

<i18n lang="json">
{
  "zh": { "hint": "移动鼠标" },
  "en": { "hint": "Move cursor" }
}
</i18n>

<style scoped>
.ps-wrap {
  position: relative;
  width: 100%;
  height: 100%;
  min-height: 480px;
  background: radial-gradient(ellipse at center, #2a0e0c 0%, #100604 70%);
  overflow: hidden;
  border-radius: 12px;
  cursor: crosshair;
}
.ps-hint {
  position: absolute;
  left: 16px;
  bottom: 14px;
  z-index: 10;
  font-family: 'Consolas', monospace;
  font-size: 11px;
  letter-spacing: 3px;
  color: rgba(255, 255, 255, 0.45);
  pointer-events: none;
  user-select: none;
}
</style>