three.js从盒子到链条的程序化三维实现

1 阅读6分钟

开源仓库: github.com/qdcxj/three… · React Three Fiber · Vite · TypeScript


效果与设计目标

/chain 页:一条悬挂着的金属锁链,链环呈长圆形(跑道形),相邻环错位 90° 穿插,带 PBR 贴图与环境反射,可调节链长、下垂、风力飘动。

5月24日.gif

/box-to-chain 页:许多小方块先摞成一盒金属块,按下「开始」后沿抛物轨迹飞到链上预设位置 → 方块淡出、一环 + 两颗端球长出来 → 按顺序 emissive 「焊接」,最后整条链缓动到下垂形态;链变长时相机与雾、投影范围跟着自动缩放。(第二页建议正文里再放一张自用截图或短视频 GIF,视觉冲击更大。)


如何从仓库跑起来

git clone https://github.com/qdcxj/threejs-box-to-chain.git
cd threejs-box-to-chain
npm install
npm run dev

浏览器打开控制台地址后:

  • /chainChainScene.tsx · 程序化锁链 + Leva 调参
  • / 会自动跳到 /chain · /box-to-chain 为盒子化链特效
  • 贴图在 public/textures/,通过 import.meta.env.BASE_URL 拼路径加载,部署到子路径也不易断链。

总思路:三层分工

层级做什么
几何单环 = 中心线曲线 + 圆截面扫掠;整链 = 另一条空间曲线上的密集采样与朝向
材质MeshStandardMaterial + 五张 PBR 图;diffuse 走 sRGB,其余线性;RepeatWrapping 控制细节密度
时间与相机动画阶段用单条相对时间线驱动;相机用包围盒 + 画幅 aspect 算视距,避免长链出画

下面按 Demo 1 → Demo 2 写「具体怎么实现」。


Demo 1:/chain 程序化锁链——具体实现

1)单环:为什么不用 TorusGeometry

圆环在工业链里太少见。真实链环多是直边 + 两端半圆,即 stadium(跑道)形闭合中心线。实现上继承 THREE.Curve<Vector3>,用参数 t∈[0,1)弧长拆成四段:上直边、右半圆、下直边、左半圆,都在 XY 平面闭合,长轴沿 +X

然后:

new THREE.TubeGeometry(stadiumCurve, tubularSegments, tubeRadius, radialSegments, true)

最后一个参数 closed: true 表示沿中心线闭合扫一圈,得到「一根铁条弯成环」的实体,而不是一段开口管。

2)整链走向:CatmullRomCurve3 + 控制点

悬挂感由 7 个控制点生成:X 从 -length/2 线性扫到 +length/2;Y 用对称抛物线权重 k = 1 - (2t-1)²sag 得到中间下垂;Z 用 sin(πt) * swirl 做轻微侧摆。再套 CatmullRomCurve3(..., 'catmullrom', 0.5) 得到光滑大曲线 curve

风动时不要逐环算力,只改 中间几个控制点的 Y/Z,整条曲线形变,所有实例跟着走,CPU 成本可控。

3)环数与「环间距」

沿曲线弧长 L = curve.getLength(),相邻环中心近似弧长间距 effectiveSpacing

  • Leva 里 spacing > 0:用手动间距;
  • spacing === 0:用和经验咬合相关的 linkStraight + linkRadius - tubeRadius,让小环更易「扣」进邻近环的视觉。

实例个数:floor(L / effectiveSpacing),至少 2。

4)InstancedMesh:位置和四元数怎么写?

对每个实例索引 i

  • t = i / (count-1)curve.getPointAt(t, pos) 得位置;
  • curve.getTangentAt(t, tangent) 得切线方向(链在该点的走向)。

链环模型里长轴定义为局部 X = (1,0,0),与 stadium 曲线的直段方向一致:

  1. setFromUnitVectors(localX, tangent):把局部 X 旋到与世界切线一致;
  2. 绕切线轴再转 (i % 2) * 90°:相邻环交替,形成穿插;再加整体 twist * t * π 做整条链扭转。
  3. 四元数右乘:q_align * q_spin,写入 dummy 的 quaternion,updateMatrixmesh.setMatrixAt(i, matrix);最后 instanceMatrix.needsUpdate = true

这样既省 draw call,又避免上千个 <mesh> 触发 React reconcile。

5)PBR 与贴图轴向

useTexture 一次拉五张:map / normalMap / roughnessMap / metalnessMap / displacementMap。对每条纹理设置 RepeatWrappingrepeat.u 用 Leva textureRepeat(沿环周铺开),mapSRGBColorSpace

位移 displacementScale 开大容易阴影痤疮,需要和 bias 一起压着调。场景里再配合 Environment(如 warehouse HDR)ContactShadows,金属才「站得住地面」。

两端 EndCap:两个小球放在 CatmullRom 首尾控制点位置,共用同一套贴图材质的视觉锚点。


Demo 2:/box-to-chain——单时间线如何实现「剧情动画」?

1)为什么在 useEffectnew THREE.Mesh

方块数量是 gridDim³(例如 4³ = 64)。若每个 voxel 写一个 React 子组件,useFrame 里再通过 useState 更新,会把 React 和 60fps 绑死。

做法是:挂载一个根 THREE.Group,三层 for 循环里 group.add(box),把引用塞进 unitsRef: UnitObj[]。每个单元结构:

  • 外层 Group:负责整体位移、旋转(从盒子格点飞向链上一点);
  • boxBoxGeometry,阶段 1 可见;
  • sub:子组,内含 TubeGeometry(StadiumCurve) 环 + 两颗 SphereGeometry 端球,阶段初始 scale = 0、不可见。

这样 一整页动画只跑一次 React 渲染树,动力学全在 useFrame 里改 position / quaternion / scale / material.emissive

2)阶段常量(秒)与时间线拆分

源码里常量大致为:

T_EXPLODE · T_MORPH · T_CONNECT_PER(每项焊接节奏) · T_CONNECT_PAD · T_SETTLE
elapsed = clock.elapsedTime - t0,再算:

  • tExplode0 … T_EXPLODE——位置从 gridPos lerp 到「链上的目标点」,y 叠加 sin(u·π)·arcHeight 做抛物感;朝向从单位四元数 slerp 到链上目标的 q_target(与 Demo 1 相仿:对齐切线 + 交错 90°)。
  • tMorph:立方 scale → 0sub scale → 0→1;并叠一层 冷色 emissive 脉冲sin(π·progress))表示「化形」。
  • tConnect:对每个 i,在 tConnectStart + i * T_CONNECT_PER 附近给 暖色 emissive 钟形脉冲,读起来像从左到右咬合一环。
  • tSettle:整条链的中间下垂量 sag 从 0 插到 finalSag(仍用 k = 1-(2ti-1)² 沿链分配高度差);同时两端 挂点球可按 settle 进度 缩放显现

phaseRefidle | running | done)只在 React 侧改;useFrame 里读 phaseRef.current,避免异步 state。进入 running 的第一帧:记录 t0 并把每个单元复位到 gridPos,避免「重播」从上一条结束态突兀跳变。

3)链在空间中的排布:linkSpacing

链上第 i 个单元的水平参数仍可记 ti = i/(count-1)。首尾中心距:

chainLength = (count - 1) * linkSpacing(一环时退化为单笔间距占位)。

(x(ti) = -chainLength/2 + ti · chainLength),与大曲线共用同一套「抛物下垂」表达式,末端切线在 chainLength 很小时退化避免 normalize() 炸了。


自适应相机:BoxChainCameraControls 在算什么?

gridDim 变大linkSpacing 变大,水平跨度猛增。组件在 useLayoutEffect 里读 size.width / size.height

  1. 用链长 + chainBaseY + arcHeight + finalSag 估一个轴对齐包围范围(留 margin);
  2. 对透视相机,给定 候选 FOV,计算 竖直视距 / 横向视距(横向要乘 aspect),取 max 得到能框住整张链的 dist
  3. FOV ∈ [约32°, 55°]少量迭代抬 FOV,避免只靠拉远导致画面像「望远镜」;
  4. 相机放在斜上方 (dx, centerY+ε, dz)lookAt(0, centerY, 0);同步 OrbitControls.targetmaxDistance、以及场景里 雾、接触阴影范围、平行光阴影正交半宽(在 useSceneFraming 里与链长同比例放大),减少「链看见了、影子却裁没了」的违和感。

文件地图(读代码从这里点进去)

路径内容
src/Chain.tsxStadiumCurveInstancedMesh、CatmullRom、风动、PBR
src/scenes/ChainScene.tsx/chain 场景与 Leva
src/scenes/BoxToChainScene.tsx盒子化链时间线、挂点、自适应相机
src/App.tsx路由总线

小结

  • 单环:自定义 Curve + 闭合 TubeGeometry
  • 整链CatmullRom 定空间走向 + 实例矩阵定每环位姿(长轴贴切线 + 90° 交错)。
  • 盒子化链命令式 Three 对象 + 单时间线 useFrame,比「大量 React 组件 + 多个 tween」更稳、更好调时长。
  • 相机与雾影:与 链长、画幅比例 绑定,才能在大屏和「链特别长」两种情况下都不穿帮。