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

开源仓库: 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 曲线的直段方向一致:

  1. setFromUnitVectors(localX, tangent):把局部 X 旋到与世界切线一致;
  2. 绕切线轴再转 (i % 2) * 90° :相邻环交替,形成穿插;再加整体 twist * t * π 做整条链扭转。
  3. 四元数右乘: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------位置从 gridPos lerp 到「链上的目标点」,y 叠加 sin(u·π)·arcHeight 做抛物感;朝向从单位四元数 slerp 到链上目标的 q_target(与 Demo 1 相仿:对齐切线 + 交错 90°)。
  • tMorph :立方 scale → 0,sub scale → 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:

  1. 用链长 + chainBaseY + arcHeight + finalSag 估一个轴对齐包围范围(留 margin);
  2. 对透视相机,给定 候选 FOV ,计算 竖直视距 / 横向视距 (横向要乘 aspect),取 max 得到能框住整张链的 dist;
  3. 在 FOV ∈ [约32°, 55°] 内少量迭代抬 FOV,避免只靠拉远导致画面像「望远镜」;
  4. 相机放在斜上方 (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」更稳、更好调时长。
  • 相机与雾影 :与 链长、画幅比例 绑定,才能在大屏和「链特别长」两种情况下都不穿帮。
相关推荐
涉密IT资质笔记2 分钟前
涉密人员脱密期管理规范:期限分级模型、就业限制边界与违规认定标准
java·服务器·前端·网络·数据库
u01110267510 分钟前
工具页案例图如何编写 alt让图片说明与处理场景对应
java·前端·javascript·图像处理·人工智能·算法·ai作画
事圆则缓14 分钟前
Flutter 状态管理框架对比(五):GetX 的响应式、依赖注册与生命周期
前端·javascript·flutter
广州智造1 小时前
Radioss复合材料使用的单元属性
前端·设计·cad·建模·cae·radioss
张宏宇2 小时前
我把微软开源的 tgrep 做成了本地版 GitHub Code Search:45,629 个文件里搜一次 10ms
前端·后端
huakoh2 小时前
MCP 调用超时后别急着重试:三步读回分清执行状态
前端
打工仔折腾 AI2 小时前
把模型切换交给平台:用蓝耘智能路由搭建商品评论分析工具
java·服务器·前端·后端·python·性能优化·ai agent 实战
不可能片场2 小时前
Electron 发布标题的冒号 击穿命令行解析
前端·electron
500842 小时前
React Native for OpenHarmony 实战:三方库 react-native-crypto-js 的鸿蒙化适配指南
javascript·react native·性能优化·electron·harmonyos
reeswell2 小时前
我开源了 inspect-devtools —— 让 AI 终于能"看见"你屏幕上的组件
前端·人工智能