ToolKitToolKit工具箱

用手势驱动液态粒子

需要摄像头权限。识别全部在浏览器本地完成, 视频不上传任何服务器。

查看源码 / Source点击展开
<!--
  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>