<!--
HandFluid.vue — 手势驱动的液态粒子流。
父组件通过 ref 暴露 setHands(hands) 推帧, 本组件只负责渲染 + 物理。
-->
<script setup lang="ts">
import { onMounted, onBeforeUnmount, useTemplateRef } from 'vue'
import * as THREE from 'three'
import type { HandSnapshot } from './useHandTracker'
import { createBloomComposer } from '@/components/effects/bloom'
const containerRef = useTemplateRef<HTMLDivElement>('container')
// ============= 参数 =============
const COUNT = 30000
// 物理空间: x in [-aspect, aspect], y in [-1, 1]
let aspect = 1
const POS_DAMP = 0.92
const RETURN = 0.012 // 拉回原点的弹簧
const POINT_BASE = 1.2
// 每个粒子的 anchor(初始位置)在一个圆盘里随机分布
const positions = new Float32Array(COUNT * 3) // 当前位置 (动)
const anchors = new Float32Array(COUNT * 2) // 初始锚点 (静)
const velocities = new Float32Array(COUNT * 2) // 速度
const seeds = new Float32Array(COUNT) // 0~1 随机种子, shader 抖动用
for (let i = 0; i < COUNT; i++) {
// 圆盘随机分布: 半径 sqrt(rand) 让面积均匀
const r = Math.sqrt(Math.random()) * 0.85
const a = Math.random() * Math.PI * 2
const x = Math.cos(a) * r
const y = Math.sin(a) * r
anchors[i * 2] = x
anchors[i * 2 + 1] = y
positions[i * 3] = x
positions[i * 3 + 1] = y
positions[i * 3 + 2] = 0
seeds[i] = Math.random()
}
let renderer: THREE.WebGLRenderer
let scene: THREE.Scene
let camera: THREE.OrthographicCamera
let geometry: THREE.BufferGeometry
let material: THREE.ShaderMaterial
let points: THREE.Points
let posAttr: THREE.BufferAttribute
let bloom: ReturnType<typeof createBloomComposer> | null = null
let rafId = 0
let resizeObserver: ResizeObserver | null = null
// 当前帧的"力"中心。多只手 → 多个力点
interface ForcePoint {
x: number
y: number
/** 引力(>0) 或排斥力(<0) */
strength: number
/** 影响半径 */
radius: number
/** 切向旋转分量(漩涡) */
swirl: number
}
let forces: ForcePoint[] = []
// 全屏方向风场, 由"挥动"手势触发, 自然衰减
let windX = 0
let windY = 0
const WIND_DAMP = 0.86
const vert = /* glsl */`
attribute float aSeed;
uniform float uTime;
uniform float uAspect;
uniform float uPointSize;
uniform float uPixelRatio;
varying float vSpeed;
varying float vSeed;
void main() {
vec2 pos = position.xy;
vec2 ndc = vec2(pos.x / uAspect, pos.y);
gl_Position = vec4(ndc, 0.0, 1.0);
vSpeed = position.z; // 复用 z 通道传速度大小
vSeed = aSeed;
// 速度大粒子也大, 给"拉伸"感
gl_PointSize = uPointSize * uPixelRatio * (1.0 + vSpeed * 8.0);
}
`
const frag = /* glsl */`
precision highp float;
varying float vSpeed;
varying float vSeed;
uniform vec3 uColdColor; // 冷色 (远处)
uniform vec3 uWarmColor; // 热色 (有力的地方)
uniform vec3 uHotColor; // 极热(被吸引/捏合)
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.4);
// 速度低=冷青, 速度中=暖, 速度高=白热
float t1 = smoothstep(0.0, 0.06, vSpeed);
float t2 = smoothstep(0.05, 0.18, vSpeed);
vec3 col = mix(uColdColor, uWarmColor, t1);
col = mix(col, uHotColor, t2);
// 加点种子抖动让群体不死板
float flicker = 0.92 + 0.16 * fract(vSeed * 17.13);
gl_FragColor = vec4(col * halo * flicker * 1.4, 1.0);
}
`
function buildScene(w: number, h: number) {
aspect = w / h
scene = new THREE.Scene()
// 自定义正交相机: x ∈ [-1,1] → ndc 在 vert 里手动除 aspect
camera = new THREE.OrthographicCamera(-1, 1, 1, -1, -1, 1)
geometry = new THREE.BufferGeometry()
posAttr = new THREE.BufferAttribute(positions, 3)
posAttr.setUsage(THREE.DynamicDrawUsage)
geometry.setAttribute('position', posAttr)
geometry.setAttribute('aSeed', new THREE.BufferAttribute(seeds, 1))
material = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uAspect: { value: aspect },
uPointSize: { value: POINT_BASE },
uPixelRatio: { value: Math.min(window.devicePixelRatio, 2) },
uColdColor: { value: new THREE.Color('#0EA5E9') }, // 青蓝
uWarmColor: { value: new THREE.Color('#22D3EE') }, // 浅青
uHotColor: { value: new THREE.Color('#FFFFFF') }, // 白热
},
vertexShader: vert,
fragmentShader: frag,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
})
points = new THREE.Points(geometry, material)
points.frustumCulled = false
scene.add(points)
bloom = createBloomComposer(renderer, scene, camera, {
intensity: 1.4,
threshold: 0.2,
})
bloom.setSize(w, h)
}
function setForces(f: ForcePoint[]) {
forces = f
}
function step() {
// 风场每帧衰减(独立于粒子)
windX *= WIND_DAMP
windY *= WIND_DAMP
// 物理: 每个粒子受锚点弹簧 + 力点 + 全屏风
for (let i = 0; i < COUNT; i++) {
const ix = i * 2, iz = i * 3
let px = positions[iz], py = positions[iz + 1]
let vx = velocities[ix], vy = velocities[ix + 1]
const ax = anchors[ix], ay = anchors[ix + 1]
// 锚点回弹
vx += (ax - px) * RETURN
vy += (ay - py) * RETURN
// 全屏方向风(扇动手势触发)
vx += windX
vy += windY
// 力点
for (let k = 0; k < forces.length; k++) {
const f = forces[k]
const dx = f.x - px, dy = f.y - py
const d2 = dx * dx + dy * dy
const r2 = f.radius * f.radius
if (d2 < r2) {
const d = Math.sqrt(d2) + 0.05 // 加常数避免极近时爆炸
const falloff = 1 - d / f.radius
// 径向: 单位方向向量 * 强度 * 衰减
const ux = dx / d, uy = dy / d
const mag = f.strength * falloff
vx += ux * mag
vy += uy * mag
// 切向(漩涡): 垂直于径向方向
if (f.swirl !== 0) {
const sw = f.swirl * falloff
vx += -uy * sw
vy += ux * sw
}
}
}
vx *= POS_DAMP
vy *= POS_DAMP
px += vx
py += vy
// 边界软包裹: 走太远拉回(避免飞出视野)
const maxR = 1.6
const r2 = px * px + py * py
if (r2 > maxR * maxR) {
const d = Math.sqrt(r2)
px = (px / d) * maxR
py = (py / d) * maxR
vx *= -0.3
vy *= -0.3
}
positions[iz] = px
positions[iz + 1] = py
positions[iz + 2] = Math.sqrt(vx * vx + vy * vy) // z 通道复用为速度大小
velocities[ix] = vx
velocities[ix + 1] = vy
}
posAttr.needsUpdate = true
}
function tick() {
step()
material.uniforms.uTime.value = performance.now() / 1000
bloom?.render()
rafId = requestAnimationFrame(tick)
}
// ===== 父组件接口: 推手势数据 =====
// 一次挥动只触发一次风力脉冲(避免每帧累积爆炸)
const lastWaveTrigger: Record<'L' | 'R', { dir: string | null; until: number }> = {
L: { dir: null, until: 0 },
R: { dir: null, until: 0 },
}
function setHands(hands: HandSnapshot[]) {
// 把归一化 [0,1] 坐标转到我们的物理空间 [-aspect, aspect] × [-1, 1]
// mediapipe 是左上原点, 我们 y 反向
const fps: ForcePoint[] = []
const now = performance.now()
for (const h of hands) {
const px = (h.indexTip.x - 0.5) * 2 * aspect
const py = -(h.indexTip.y - 0.5) * 2
const palmX = (h.palm.x - 0.5) * 2 * aspect
const palmY = -(h.palm.y - 0.5) * 2
// 1) 挥动 → 全屏方向风脉冲
if (h.waveDir) {
const key: 'L' | 'R' = h.isLeft ? 'L' : 'R'
const last = lastWaveTrigger[key]
// 同方向 200ms 内不重复触发, 不同方向立即触发
if (last.dir !== h.waveDir || now > last.until) {
const mag = 0.06 + h.waveStrength * 0.08 // 0.06 ~ 0.14
switch (h.waveDir) {
case 'left': windX -= mag; break
case 'right': windX += mag; break
case 'up': windY += mag; break // 物理空间 y 正向上
case 'down': windY -= mag; break
}
last.dir = h.waveDir
last.until = now + 200
}
}
// 2) 静态手势 → 局部力点
switch (h.gesture) {
case 'open':
// 张开但没在挥动时, 弱漩涡 + 弱排斥 (避免和扇风冲突)
if (!h.waveDir) {
fps.push({ x: palmX, y: palmY, strength: -0.02, radius: 1.0, swirl: 0.006 })
}
break
case 'fist':
fps.push({ x: palmX, y: palmY, strength: 0.08, radius: 1.5, swirl: 0 })
break
case 'pinch':
fps.push({ x: px, y: py, strength: 0.12, radius: 0.8, swirl: 0.04 })
break
case 'point':
fps.push({ x: px, y: py, strength: 0.04, radius: 0.6, swirl: 0.025 })
break
default:
fps.push({ x: px, y: py, strength: 0.025, radius: 0.5, swirl: 0.01 })
}
}
setForces(fps)
}
defineExpose({ setHands })
onMounted(() => {
const el = containerRef.value!
const w = el.clientWidth, h = el.clientHeight
renderer = new THREE.WebGLRenderer({
antialias: false,
stencil: false,
depth: false,
powerPreference: 'high-performance',
})
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.setSize(w, h)
el.appendChild(renderer.domElement)
buildScene(w, h)
tick()
resizeObserver = new ResizeObserver(entries => {
for (const e of entries) {
const cr = e.contentRect
aspect = cr.width / cr.height
material.uniforms.uAspect.value = aspect
renderer.setSize(cr.width, cr.height)
bloom?.setSize(cr.width, cr.height)
}
})
resizeObserver.observe(el)
})
onBeforeUnmount(() => {
cancelAnimationFrame(rafId)
resizeObserver?.disconnect()
bloom?.dispose()
geometry?.dispose()
material?.dispose()
renderer?.dispose()
if (renderer?.domElement?.parentNode) {
renderer.domElement.parentNode.removeChild(renderer.domElement)
}
})
</script>
<template>
<div ref="container" class="hf-wrap" />
</template>
<style scoped>
.hf-wrap {
position: relative;
width: 100%;
height: 100%;
min-height: 480px;
background: radial-gradient(ellipse at center, #06192a 0%, #02080f 70%);
overflow: hidden;
border-radius: 12px;
}
.hf-wrap :deep(canvas) {
display: block;
width: 100% !important;
height: 100% !important;
}
</style>