跳转至

VERCEL LANYARD 可拖拽 3D 工牌

一张挂在绳上的会议工牌:抓住它甩出去,绳子跟着摆、卡片自己转回正面 —— 物理引擎驱动的"真实感小玩具",复现自 Vercel 官方博客 Building an interactive 3D event badge with React Three Fiber(Ship 2024 注册页彩蛋)。核心只有约 80 行声明式代码。

要点

  • 绳子建模成刚体链:fixed → j1 → j2 → j3 三段 useRopeJoint(每段最大长度 1),工牌用 useSphericalJoint 锚在卡片顶部 [0, 1.45, 0],能像真挂绳一样自由摆动
  • 关节只挂 BallCollider args={[0.1]} 参与物理不参与渲染;绳子的视觉每帧把四个刚体位置塞进 CatmullRomCurve3curveType='chordal' 防过冲)采样 32 点交给 meshline
  • 用 meshline 画绳带:three 原生线宽在几乎所有 GPU 上固定 1px,meshline 才能画出有宽度、可贴图的带子;贴图 repeat={[-3, 1]} + RepeatWrapping 沿绳长平铺
  • 拖拽 = 指针 unproject 反投影到相机视线 + 刚体在 kinematicPosition(鼠标驱动)与 dynamic(物理接管)之间切换;按下时记录点击点相对卡片中心的偏移,卡片才不会跳到鼠标正下方
  • onPointerDownsetPointerCapture,拖出 canvas 也不丢事件;拖拽中对整条链 wakeUp(),否则休眠刚体不跟手
  • 对中间关节位置做 lerp 平滑消除猛拉抖动,但插值系数必须 Math.min(1, dt * speed) 钳在 [0,1] —— 超过 2 会每帧放大直至坐标爆成 NaN、绳子整条消失(实测掉帧时 delta 突增触发,坐标飙到 1e105)
  • 每帧给卡片反向角速度 y - rot.y * 0.25,让正面始终倾向朝屏幕;angularDamping/linearDamping = 2 让甩动自然衰减
  • 环境光用 drei 的 Environment + 四片 Lightformer 手搓,不依赖外部 HDRI 文件,卡片的 clearcoat 反光全靠它
  • 免构建运行时:htm tagged template 替代 JSX + import map 指向 /vendor/ 下 esbuild 预打的 ESM 单文件;react / three / fiber 这类"单例敏感"包打包时 external 化,由 import map 保证全页唯一实例
  • esbuild external 是包名前缀匹配(react-dom 会连 react-dom/client 一起 external 掉造成自引用死循环),wrapper 入口要 require.resolve 成真实文件路径再 import
  • CJS 包(react-dom、react-reconciler)对 external 依赖的 require() 在 ESM 产物里会炸,产物 banner 里 import 这些包并提供模块作用域 require shim 即可救回

第三方库

作用
three WebGL 3D 引擎(走全站共享 /vendor/three/,r185)
@react-three/fiber three 的 React 渲染器,场景全部 JSX/htm 声明式
@react-three/drei 只用 4 个导出:useGLTF / useTexture / Environment / Lightformer
@react-three/rapier Rapier 物理引擎(Rust→WASM)封装:刚体、碰撞体、rope/spherical joint
meshline 有宽度可贴图的线渲染,绳带的视觉本体
htm JSX 的免构建替身,html\`` 直接跑

模型 tag.glb(含 card / clip / clamp 三个 node)与贴图 band.jpg 取自 Vercel 官方 demo CDN,已 vendor 进本文 assets。

已知瑕疵

绳子与卡扣连接处偶发闪烁/破面:meshline 在 shader 里用屏幕空间投影算带宽方向,绳末端切线接近指向相机时投影退化、方向随机翻转,参数层面无解。根治要换渲染方式(每帧重建 TubeGeometry,或自建 ribbon 几何在 CPU 算宽度方向、退化帧沿用上帧方向),收录版保持与原文一致未做替换。

完整实现

  • 运行入口 —— 自包含单页,逻辑未压缩
  • vendor 打包脚本存档 —— esbuild 把 fiber/drei/rapier/meshline 打成 external 化 ESM 的配方;drei 需要新导出时在 stdin 入口加名字重跑
  • 运行时库真身在全站共享 /vendor/react-esm/(react 19 ESM 五件套)与 /vendor/r3f/(fiber / drei-slim / rapier / meshline)
assets/vercel-lanyard-demo/index.html —— 演示单页源码(自包含、未压缩)
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Vercel Lanyard — 可拖拽 3D 工牌(免构建运行时 React)</title>
<style>
  * { box-sizing: border-box; margin: 0; padding: 0; }
  html, body {
    width: 100%; height: 100%; overflow: hidden; background: #000;
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
    touch-action: none; -webkit-user-select: none; user-select: none;
  }
  #root { width: 100%; height: 100%; }
  canvas { display: block; }
  .hint {
    position: fixed; bottom: 20px; left: 0; right: 0; text-align: center;
    color: rgba(255,255,255,.45); font-size: 13px; letter-spacing: .08em;
    pointer-events: none; z-index: 10;
  }
  .logo {
    position: fixed; top: 22px; left: 26px; color: #fff;
    font-size: 15px; font-weight: 600; letter-spacing: .02em;
    pointer-events: none; z-index: 10;
  }
</style>
<!-- 运行时依赖全部来自全站共享 /vendor/,无 CDN、无构建。
     react/three/fiber 这些"单例敏感"的包在打包时被 external 化,
     由这份 import map 统一指到唯一文件,保证全页只有一份实例。 -->
<script type="importmap">
{
  "imports": {
    "react": "/vendor/react-esm/react.js",
    "react/jsx-runtime": "/vendor/react-esm/jsx-runtime.js",
    "scheduler": "/vendor/react-esm/scheduler.js",
    "react-dom": "/vendor/react-esm/react-dom.js",
    "react-dom/client": "/vendor/react-esm/react-dom-client.js",
    "three": "/vendor/three/three.module.min.js",
    "@react-three/fiber": "/vendor/r3f/fiber.js",
    "@react-three/drei": "/vendor/r3f/drei-slim.js",
    "@react-three/rapier": "/vendor/r3f/rapier.js",
    "meshline": "/vendor/r3f/meshline.js",
    "htm": "/vendor/htm.module.js"
  }
}
</script>
</head>
<body>
<div class="logo">SHIP 2024</div>
<div id="root"></div>
<div class="hint">拖动工牌试试 — 松手后它会自己甩回去</div>

<script type="module">
import * as THREE from 'three'
import { createElement, useEffect, useRef, useState, Fragment } from 'react'
import { createRoot } from 'react-dom/client'
import { Canvas, extend, useFrame, useThree } from '@react-three/fiber'
import { Environment, Lightformer, useGLTF, useTexture } from '@react-three/drei'
import {
  BallCollider, CuboidCollider, Physics, RigidBody,
  useRopeJoint, useSphericalJoint,
} from '@react-three/rapier'
import { MeshLineGeometry, MeshLineMaterial } from 'meshline'
import htm from 'htm'

// htm:JSX 的免构建替身 —— html`<mesh>` 等价于 JSX 的 <mesh>,组件用 <${Comp}> 插值
const html = htm.bind(createElement)

// 把 meshline 的类注册进 R3F,使其可以作为 <meshLineGeometry /> / <meshLineMaterial /> 使用
extend({ MeshLineGeometry, MeshLineMaterial })

// 资产与本页同目录,相对路径引用(tag.glb 工牌模型 / band.jpg 挂绳贴图)
const GLB = './tag.glb'
const BAND = './band.jpg'
useGLTF.preload(GLB)
useTexture.preload(BAND)

function App() {
  return html`
    <${Canvas} camera=${{ position: [0, 0, 13], fov: 25 }}>
      <ambientLight intensity=${Math.PI} />
      <${Physics} interpolate gravity=${[0, -40, 0]} timeStep=${1 / 60}>
        <${Band} />
      <//>
      <!-- 用 Lightformer 手搓一套环境光,不依赖外部 HDRI 文件 -->
      <${Environment} background blur=${0.75}>
        <color attach="background" args=${['black']} />
        <${Lightformer} intensity=${2} color="white" position=${[0, -1, 5]} rotation=${[0, 0, Math.PI / 3]} scale=${[100, 0.1, 1]} />
        <${Lightformer} intensity=${3} color="white" position=${[-1, -1, 1]} rotation=${[0, 0, Math.PI / 3]} scale=${[100, 0.1, 1]} />
        <${Lightformer} intensity=${3} color="white" position=${[1, 1, 1]} rotation=${[0, 0, Math.PI / 3]} scale=${[100, 0.1, 1]} />
        <${Lightformer} intensity=${10} color="white" position=${[-10, 0, 14]} rotation=${[0, Math.PI / 2, Math.PI / 3]} scale=${[100, 10, 1]} />
      <//>
    <//>
  `
}

function Band({ maxSpeed = 50, minSpeed = 10 }) {
  // band = 绳子的 mesh;fixed 是顶部固定点;j1/j2/j3 是绳子的三个关节;card 是工牌本体
  const band = useRef()
  const fixed = useRef()
  const j1 = useRef()
  const j2 = useRef()
  const j3 = useRef()
  const card = useRef()

  // 复用的临时向量,避免每帧 new
  const vec = new THREE.Vector3()
  const ang = new THREE.Vector3()
  const rot = new THREE.Vector3()
  const dir = new THREE.Vector3()

  const segmentProps = {
    type: 'dynamic',
    canSleep: true,
    colliders: false,
    angularDamping: 2,
    linearDamping: 2,
  }

  const { nodes, materials } = useGLTF(GLB)
  const texture = useTexture(BAND)
  const { width, height } = useThree((state) => state.size)

  // 绳子的曲线:4 个控制点,由物理关节的实时位置驱动
  const [curve] = useState(
    () =>
      new THREE.CatmullRomCurve3([
        new THREE.Vector3(),
        new THREE.Vector3(),
        new THREE.Vector3(),
        new THREE.Vector3(),
      ]),
  )
  const [dragged, drag] = useState(false)
  const [hovered, hover] = useState(false)

  // 固定点 -> j1 -> j2 -> j3 串成一条绳,每段最长 1
  useRopeJoint(fixed, j1, [[0, 0, 0], [0, 0, 0], 1])
  useRopeJoint(j1, j2, [[0, 0, 0], [0, 0, 0], 1])
  useRopeJoint(j2, j3, [[0, 0, 0], [0, 0, 0], 1])
  // 工牌用球铰挂在 j3 上,锚点在卡片顶部(y = 1.45)所以它能自由摆动
  useSphericalJoint(j3, card, [[0, 0, 0], [0, 1.45, 0]])

  useEffect(() => {
    if (hovered) {
      document.body.style.cursor = dragged ? 'grabbing' : 'grab'
      return () => void (document.body.style.cursor = 'auto')
    }
  }, [hovered, dragged])

  useFrame((state, delta) => {
    // 掉帧、GC、切回标签页时 delta 会突然变大,先钳住,否则下面的插值会被打飞
    const dt = Math.min(delta, 1 / 30)

    if (dragged) {
      // 把 2D 鼠标位置反投影到 3D 空间,得到相机视线上的一点
      vec.set(state.pointer.x, state.pointer.y, 0.5).unproject(state.camera)
      dir.copy(vec).sub(state.camera.position).normalize()
      vec.add(dir.multiplyScalar(state.camera.position.length()))
      // 被拖拽时整条绳都要唤醒,否则休眠的刚体不会跟着动
      ;[card, j1, j2, j3, fixed].forEach((ref) => ref.current?.wakeUp())
      card.current?.setNextKinematicTranslation({
        x: vec.x - dragged.x,
        y: vec.y - dragged.y,
        z: vec.z - dragged.z,
      })
    }

    if (fixed.current) {
      // 对中间两个关节做插值平滑,消除猛拉时绳子的抖动
      ;[j1, j2].forEach((ref) => {
        // 顺带自愈:一旦 lerped 变成 NaN/Inf 就重置,否则 NaN 会永久传染给绳子的几何
        if (!ref.current.lerped || !Number.isFinite(ref.current.lerped.x)) {
          ref.current.lerped = new THREE.Vector3().copy(ref.current.translation())
        }
        const clampedDistance = Math.max(
          0.1,
          Math.min(1, ref.current.lerped.distanceTo(ref.current.translation())),
        )
        // alpha 必须留在 [0,1]:>1 会过冲,>2 会每帧放大直至数值爆炸成 NaN,
        // 那时 meshline 的顶点全废,绳子就退化成乱飞的三角形
        const alpha = Math.min(1, dt * (minSpeed + clampedDistance * (maxSpeed - minSpeed)))
        ref.current.lerped.lerp(ref.current.translation(), alpha)
      })

      // 用四个关节位置刷新 Catmull-Rom 曲线,再喂给 meshline
      curve.points[0].copy(j3.current.translation())
      curve.points[1].copy(j2.current.lerped)
      curve.points[2].copy(j1.current.lerped)
      curve.points[3].copy(fixed.current.translation())
      band.current.geometry.setPoints(curve.getPoints(32))

      // 给一点角速度修正,让卡片正面始终倾向于朝着屏幕
      ang.copy(card.current.angvel())
      rot.copy(card.current.rotation())
      card.current.setAngvel({ x: ang.x, y: ang.y - rot.y * 0.25, z: ang.z })
    }
  })

  curve.curveType = 'chordal'
  texture.wrapS = texture.wrapT = THREE.RepeatWrapping

  return html`
    <${Fragment}>
      <group position=${[0, 4, 0]}>
        <${RigidBody} ref=${fixed} ...${segmentProps} type="fixed" />
        <${RigidBody} position=${[0.5, 0, 0]} ref=${j1} ...${segmentProps}>
          <${BallCollider} args=${[0.1]} />
        <//>
        <${RigidBody} position=${[1, 0, 0]} ref=${j2} ...${segmentProps}>
          <${BallCollider} args=${[0.1]} />
        <//>
        <${RigidBody} position=${[1.5, 0, 0]} ref=${j3} ...${segmentProps}>
          <${BallCollider} args=${[0.1]} />
        <//>
        <${RigidBody}
          position=${[2, 0, 0]}
          ref=${card}
          ...${segmentProps}
          type=${dragged ? 'kinematicPosition' : 'dynamic'}
        >
          <${CuboidCollider} args=${[0.8, 1.125, 0.01]} />
          <group
            scale=${2.25}
            position=${[0, -1.2, -0.05]}
            onPointerOver=${() => hover(true)}
            onPointerOut=${() => hover(false)}
            onPointerUp=${(e) => {
              e.target.releasePointerCapture(e.pointerId)
              drag(false)
            }}
            onPointerDown=${(e) => {
              e.target.setPointerCapture(e.pointerId)
              // 记录点击点相对卡片中心的偏移,拖动时才不会"跳"到鼠标下
              drag(new THREE.Vector3().copy(e.point).sub(vec.copy(card.current.translation())))
            }}
          >
            <mesh geometry=${nodes.card.geometry}>
              <meshPhysicalMaterial
                map=${materials.base.map}
                map-anisotropy=${16}
                clearcoat=${1}
                clearcoatRoughness=${0.15}
                roughness=${0.3}
                metalness=${0.5}
              />
            </mesh>
            <mesh geometry=${nodes.clip.geometry} material=${materials.metal} material-roughness=${0.3} />
            <mesh geometry=${nodes.clamp.geometry} material=${materials.metal} />
          </group>
        <//>
      </group>
      <mesh ref=${band}>
        <meshLineGeometry />
        <meshLineMaterial
          color="white"
          depthTest=${false}
          resolution=${[width, height]}
          useMap
          map=${texture}
          repeat=${[-3, 1]}
          lineWidth=${1}
        />
      </mesh>
    <//>
  `
}

createRoot(document.getElementById('root')).render(html`<${App} />`)
</script>
</body>
</html>