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

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

  • /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

然后:

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,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.tsx StadiumCurveInstancedMesh、CatmullRom、风动、PBR
src/scenes/ChainScene.tsx /chain 场景与 Leva
src/scenes/BoxToChainScene.tsx 盒子化链时间线、挂点、自适应相机
src/App.tsx 路由总线

小结

  • 单环 :自定义 Curve + 闭合 TubeGeometry
  • 整链CatmullRom 定空间走向 + 实例矩阵定每环位姿(长轴贴切线 + 90° 交错)。
  • 盒子化链命令式 Three 对象 + 单时间线 useFrame,比「大量 React 组件 + 多个 tween」更稳、更好调时长。
  • 相机与雾影 :与 链长、画幅比例 绑定,才能在大屏和「链特别长」两种情况下都不穿帮。
相关推荐
用户80223847734075 小时前
Tailwind CSS 生产环境部署优化与 CDN 使用规范
前端
Oo9205 小时前
做一个用户列表页面,把模块化与语义化搞懂
javascript·全栈
共绩算力5 小时前
第四辑:8 张「印刷品与示意图」——几何海报到工间操
前端·数据库·人工智能·共绩算力
bug-100865 小时前
为什么history模式默认会请求后端资源?
前端·vue.js·nginx
Darling噜啦啦5 小时前
从零搭建一个全栈项目:前后端分离 + DOM 动态渲染实战
javascript·全栈
甜味弥漫5 小时前
《闭包:一个函数偷偷带走了我家的糖》—— 零基础也能懂的JS闭包
前端·javascript
刚子编程5 小时前
.NET 8 Web开发入门(六):Blazor 全栈开发——告别 JavaScript 焦虑
javascript·数据绑定·signalr·组件化开发·全栈开发·blazor server·c# 写前端
徐安安ye5 小时前
KV Cache的生老病死:FlashAttention里的显存管理全流程
java·服务器·前端
a1117765 小时前
VR看房 网页(开源 threejs)html
前端·开源·html·vr