Loading effect…
查看源码 / Source点击展开
<!--
ParticleSphere.vue — 鼠标交互的着色器粒子球(TresJS 版)
迁移说明(Vite→Nuxt):
- 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'
interface Props {
count?: number
radius?: number
pointSize?: number
colorA?: string
colorB?: string
/** 氛围模式: 透明底 / 无提示 / 无圆角 / 不抢交互, 供首页 hero 背景用 */
ambient?: boolean
/** 是否响应鼠标 (氛围背景层传 false) */
interactive?: boolean
/** false 时暂停渲染循环 (滚出视口 / reduced-motion 时省 GPU) */
active?: boolean
}
const { t: tr } = useI18n({ useScope: 'local' })
const props = withDefaults(defineProps<Props>(), {
count: 14000,
radius: 1.6,
pointSize: 3.0,
colorA: '#4FC3F7',
colorB: '#00E5C7',
ambient: false,
interactive: true,
active: true,
})
// ===== 几何:在球面上均匀采样(Fibonacci 球面)=====
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
}
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 vFresnel;
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 total = breathe + disp + ripple;
pos += dir * total;
vDisp = total;
vec4 mv = modelViewMatrix * vec4(pos, 1.0);
// 法线相对相机方向 → fresnel(边缘亮、正对暗)
vec3 viewDir = normalize(-mv.xyz);
vec3 nrm = normalize((modelViewMatrix * vec4(dir, 0.0)).xyz);
vFresnel = pow(1.0 - abs(dot(nrm, viewDir)), 2.0);
gl_Position = projectionMatrix * mv;
float atten = 5.0 / -mv.z;
gl_PointSize = uPointSize * uPixelRatio * (0.8 + total * 1.0) * atten;
}
`
const fragmentShader = /* glsl */`
uniform vec3 uColorA;
uniform vec3 uColorB;
varying float vDisp;
varying float vFresnel;
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);
// fresnel 边缘点用接近白的青光强化
col += vec3(0.5, 0.9, 1.0) * vFresnel * 0.8;
// 加性混合下颜色直接相加 — 用 halo 既做亮度又做"alpha"
gl_FragColor = vec4(col * halo * 1.25, 1.0);
}
`
// SSG 守卫: 预渲染在 Node 端无 window。组件虽由 <ClientOnly> 包裹, 仍防御性取值。
const pixelRatio = "undefined" !== 'undefined' ? Math.min(window.devicePixelRatio, 2) : 1
// ===== 直接构造完整 three.js 对象,markRaw 避免响应式代理 =====
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) {
if (!props.interactive) return
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() {
if (!props.interactive) return
mouseStrTarget = 0
}
// ===== 渲染循环(onLoop 是 EventHook,返回 { off })=====
const { onLoop } = useRenderLoop()
const { off } = onLoop(({ elapsed }) => {
if (!props.active) return
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" :class="{ ambient }" @pointermove="onPointerMove" @pointerleave="onPointerLeave">
<TresCanvas
:clear-color="ambient ? 'transparent' : '#070b14'"
:alpha="ambient"
: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 v-if="!ambient" 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, #0a1424 0%, #04060c 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;
}
/* 氛围模式: 去掉 demo 外观(底色/圆角/最小高/十字光标), 纯透明叠层 */
.ps-wrap.ambient {
background: transparent;
min-height: 0;
border-radius: 0;
cursor: default;
}
</style>