ToolKitToolKit工具箱
Loading effect…
查看源码 / Source点击展开
ParticleSphereLygia.vue
<!--
  ParticleSphereLygia.vue — TresJS 粒子球,把手写 value noise 换成 lygia 的 simplex noise。
  视觉差异:simplex 各向同性、过渡更平滑,且每个采样点同时输出 3 个独立维度,
  这里把 noise 升级成"沿球面法线方向流动"的位移而不是单一标量振幅。

  迁移说明(Vite→Nuxt):
  - resolveIncludes 入口路径 /src/... → /app/...
  - window.devicePixelRatio 加 SSG 守卫(虽然由 <ClientOnly> 包裹, 仍防御性处理)
  - 本组件须在父级用 <ClientOnly> 包裹, 不在服务端渲染。
-->
<script setup lang="ts">
import { markRaw, onBeforeUnmount } from 'vue'
import * as THREE from 'three'
import { TresCanvas, useRenderLoop } from '@tresjs/core'
import { resolveIncludes } from '@/shaders/lygia/resolveIncludes'

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

const props = withDefaults(defineProps<Props>(), {
  count: 14000,
  radius: 1.6,
  pointSize: 3.0,
  colorA: '#FFB347',
  colorB: '#FF4FA3',
})

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
}

// 把 lygia 的 #include 在编译期展开,得到无 include 的纯 GLSL
// (相对 lygia 根的 key, 见 resolveIncludes 迁移说明)
const lygiaSnoise = resolveIncludes('generative/snoise.glsl')

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 vHeat;

  ${lygiaSnoise}

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

    float breathe = sin(uTime * 0.4 + aSeed * 6.28) * 0.03;

    // 双层 fbm:大尺度地壳 + 小尺度沸腾
    float n1 = snoise(dir * 1.4 + vec3(0.0, 0.0, uTime * 0.30));
    float n2 = snoise(dir * 4.0 + vec3(uTime * 0.45, 0.0, 0.0));
    float n3 = snoise(dir * 9.0 + vec3(0.0, uTime * 0.6, 0.0));
    float fbm = n1 * 0.6 + n2 * 0.28 + n3 * 0.12;
    float disp = fbm * 0.7; // 振幅 2x 原来,球面会"沸腾扭曲"

    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 total = breathe + disp + ripple;
    pos += dir * total;
    vDisp = total;
    // 隆起处更"灼热"
    vHeat = smoothstep(-0.1, 0.6, disp);

    vec4 mv = modelViewMatrix * vec4(pos, 1.0);
    gl_Position = projectionMatrix * mv;
    float atten = 5.0 / -mv.z;
    gl_PointSize = uPointSize * uPixelRatio * (0.7 + total * 1.6) * atten;
  }
`

const fragmentShader = /* glsl */`
  uniform vec3 uColorA;
  uniform vec3 uColorB;
  varying float vDisp;
  varying float vHeat;
  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.3, 0.3, vDisp);
    vec3 col = mix(uColorA, uColorB, t);
    // 灼热区域加白热色
    col += vec3(1.5, 0.8, 0.3) * vHeat * vHeat * 0.7;
    gl_FragColor = vec4(col * halo * 1.35, 1.0);
  }
`

// SSG 守卫: 预渲染在 Node 端无 window。组件虽由 <ClientOnly> 包裹, 仍防御性取值。
const pixelRatio = "undefined" !== 'undefined' ? Math.min(window.devicePixelRatio, 2) : 1

const geometry = buildGeometry()
const 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: pixelRatio },
    uColorA:     { value: new THREE.Color(props.colorA) },
    uColorB:     { value: new THREE.Color(props.colorB) },
  },
  vertexShader,
  fragmentShader,
  transparent: true,
  depthWrite: false,
  blending: THREE.AdditiveBlending,
})
const points = markRaw(new THREE.Points(geometry, material))

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

function onPointerMove(e: PointerEvent) {
  const target = e.currentTarget as HTMLElement
  const rect = target.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
}

const { onLoop } = useRenderLoop()
const { off } = onLoop(({ elapsed }) => {
  const u = material.uniforms
  u.uTime.value = elapsed
  u.uMouse.value.lerp(mouseTarget, 0.1)
  u.uMouseStr.value += (mouseStrTarget - u.uMouseStr.value) * 0.08
  points.rotation.y = elapsed * 0.08
  points.rotation.x = Math.sin(elapsed * 0.15) * 0.2
})

onBeforeUnmount(() => {
  off()
  geometry.dispose()
  material.dispose()
})
</script>

<template>
  <div class="ps-wrap" @pointermove="onPointerMove" @pointerleave="onPointerLeave">
    <TresCanvas
      clear-color="#120814"
      :alpha="false"
      :antialias="true"
      :dpr="[1, 2]"
    >
      <TresPerspectiveCamera :position="[0, 0, 5]" :fov="55" :near="0.1" :far="100" />
      <TresAmbientLight :intensity="0.4" />
      <primitive :object="points" />
    </TresCanvas>
    <div class="ps-hint">{{ tr('hint') }}</div>
  </div>
</template>

<i18n lang="json">
{
  "zh": { "hint": "lygia simplex noise · 移动鼠标" },
  "en": { "hint": "lygia simplex noise · move cursor" }
}
</i18n>

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