开源仓库: github.com/qdcxj/three... · React Three Fiber · Vite · TypeScript
效果与设计目标
/chain 页:一条悬挂着的金属锁链 ,链环呈长圆形(跑道形) ,相邻环错位 90° 穿插,带 PBR 贴图与环境反射,可调节链长、下垂、风力飘动。

/box-to-chain 页:许多小方块先摞成一盒金属块 ,按下「开始」后沿抛物轨迹飞到链上预设位置 → 方块淡出、一环 + 两颗端球 长出来 → 按顺序 emissive 「焊接」 ,最后整条链缓动到下垂形态;链变长时相机与雾、投影范围跟着自动缩放。(第二页建议正文里再放一张自用截图或短视频 GIF,视觉冲击更大。)
如何从仓库跑起来
bash
git clone https://github.com/qdcxj/threejs-box-to-chain.git
cd threejs-box-to-chain
npm install
npm run dev
浏览器打开控制台地址后:
/chain:ChainScene.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。
然后:
ts
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 曲线的直段方向一致:
setFromUnitVectors(localX, tangent):把局部 X 旋到与世界切线一致;- 绕切线轴再转
(i % 2) * 90°:相邻环交替,形成穿插;再加整体twist * t * π做整条链扭转。 - 四元数右乘:
q_align * q_spin,写入dummy的 quaternion,updateMatrix,mesh.setMatrixAt(i, matrix);最后instanceMatrix.needsUpdate = true。
这样既省 draw call,又避免上千个 <mesh> 触发 React reconcile。
5)PBR 与贴图轴向
useTexture 一次拉五张:map / normalMap / roughnessMap / metalnessMap / displacementMap。对每条纹理设置 RepeatWrapping ,repeat.u 用 Leva textureRepeat(沿环周铺开),map 设 SRGBColorSpace。
位移 displacementScale 开大容易阴影痤疮,需要和 bias 一起压着调。场景里再配合 Environment(如 warehouse HDR) 与 ContactShadows,金属才「站得住地面」。
两端 EndCap :两个小球放在 CatmullRom 首尾控制点位置,共用同一套贴图材质的视觉锚点。
Demo 2:/box-to-chain------单时间线如何实现「剧情动画」?
1)为什么在 useEffect 里 new THREE.Mesh?
方块数量是 gridDim³ (例如 4³ = 64)。若每个 voxel 写一个 React 子组件,useFrame 里再通过 useState 更新,会把 React 和 60fps 绑死。
做法是:挂载一个根 THREE.Group ,三层 for 循环里 group.add(box),把引用塞进 unitsRef: UnitObj[]。每个单元结构:
- 外层
Group:负责整体位移、旋转(从盒子格点飞向链上一点); box:BoxGeometry,阶段 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,再算:
tExplode:0 ... T_EXPLODE------位置从gridPoslerp 到「链上的目标点」,y叠加sin(u·π)·arcHeight做抛物感;朝向从单位四元数 slerp 到链上目标的q_target(与 Demo 1 相仿:对齐切线 + 交错 90°)。tMorph:立方scale → 0,subscale → 0→1;并叠一层 冷色 emissive 脉冲 (sin(π·progress))表示「化形」。tConnect:对每个i,在tConnectStart + i * T_CONNECT_PER附近给 暖色 emissive 钟形脉冲,读起来像从左到右咬合一环。tSettle:整条链的中间下垂量sag从 0 插到finalSag(仍用k = 1-(2ti-1)²沿链分配高度差);同时两端 挂点球 可按 settle 进度 缩放显现。
phaseRef(idle | 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:
- 用链长 +
chainBaseY+arcHeight+finalSag估一个轴对齐包围范围(留 margin); - 对透视相机,给定 候选 FOV ,计算 竖直视距 / 横向视距 (横向要乘
aspect),取max得到能框住整张链的dist; - 在
FOV ∈ [约32°, 55°]内少量迭代抬 FOV,避免只靠拉远导致画面像「望远镜」; - 相机放在斜上方
(dx, centerY+ε, dz),lookAt(0, centerY, 0);同步OrbitControls.target、maxDistance、以及场景里 雾、接触阴影范围、平行光阴影正交半宽 (在useSceneFraming里与链长同比例放大),减少「链看见了、影子却裁没了」的违和感。
文件地图(读代码从这里点进去)
| 路径 | 内容 |
|---|---|
src/Chain.tsx |
StadiumCurve、InstancedMesh、CatmullRom、风动、PBR |
src/scenes/ChainScene.tsx |
/chain 场景与 Leva |
src/scenes/BoxToChainScene.tsx |
盒子化链时间线、挂点、自适应相机 |
src/App.tsx |
路由总线 |
小结
- 单环 :自定义
Curve+ 闭合TubeGeometry。 - 整链 :CatmullRom 定空间走向 + 实例矩阵定每环位姿(长轴贴切线 + 90° 交错)。
- 盒子化链 :命令式 Three 对象 + 单时间线
useFrame,比「大量 React 组件 + 多个 tween」更稳、更好调时长。 - 相机与雾影 :与 链长、画幅比例 绑定,才能在大屏和「链特别长」两种情况下都不穿帮。