阅读视图

发现新文章,点击刷新页面。

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

开源仓库: 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.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」更稳、更好调时长。
  • 相机与雾影:与 链长、画幅比例 绑定,才能在大屏和「链特别长」两种情况下都不穿帮。

树上挂苹果还是挂玻璃球?Three.js 程序化果实的完整实现指南

树上挂苹果还是挂玻璃球?Three.js 程序化果实的完整实现指南

基于 EZ-Tree 扩展:从扫描贴图苹果到物理玻璃球,一套 InstancedMesh 架构搞定两种风格。

项目源码github.com/qdcxj/ez-tr…


前言:为什么这件事比「加个 Sphere」难得多?

我在一个 Three.js 程序化树 项目里做挂果功能,一开始想法很简单:

分支上 new SphereGeometry,贴一张苹果图,完事。

结果踩了三个大坑:

  1. 苹果贴图不是普通 UV 图,而是扫描仪输出的 Atlas 图集——两个圆盘(果柄端 + 花萼端)+ 一条果柄,背景全黑。
  2. 直接贴到球体上,苹果变成「红黑斑马纹」,UV 越界采到了黑色背景。
  3. 后来加了 玻璃球,发现不能和苹果各写一套逻辑——否则 UI、分支采样、实例化渲染全得复制一遍。

最终方案是:一套挂果管线 + 两种材质策略,UI 里一键切换 Apple / GlassBall

本文把完整过程拆开讲,你可以直接抄到自己的 Three.js 项目里。


最终效果一览

preview-apple.png

preview-glass-ball.png

左:Apple 扫描 Atlas 贴图苹果 · 右:GlassBall 程序化物理玻璃球

类型 几何体 材质 贴图 果柄
Apple SphereGeometry + 自定义 UV MeshPhongMaterial 扫描 Atlas
GlassBall SphereGeometry 默认 UV MeshPhysicalMaterial 无(纯程序化)

两种类型共用:分支采样、实例数据、InstancedMesh 渲染、UI 参数面板


整体架构

flowchart TB
    subgraph UI["UI 面板 (ui.js)"]
        Type["Type: apple | glassBall"]
        Common["Count / Size / BranchLevel ..."]
        AppleOnly["Shininess / CapScale"]
        GlassOnly["Transmission / IOR / Thickness"]
    end

    subgraph Options["配置 (options.js)"]
        Fruits["tree.options.fruits"]
    end

    subgraph Tree["树生成 (tree.js)"]
        Gen["generateFruits()"]
        Inst["fruits.instances[]"]
        Mesh["createFruitsGeometry()"]
    end

    subgraph Material["材质 (fruitMaterial.js)"]
        AppleUV["applyAppleSphereUVs()"]
        AppleMat["createAppleBodyMaterial()"]
        GlassMat["createGlassBallMaterial()"]
    end

    subgraph Tex["纹理 (textures.js)"]
        Load["ensureFruitMaps() — 仅 apple"]
    end

    Type --> Fruits
    Common --> Fruits
    Fruits --> Gen --> Inst --> Mesh
    Load --> Fruits
    Mesh -->|apple| AppleUV --> AppleMat
    Mesh -->|glassBall| GlassMat

设计原则:

  • 生成逻辑统一:不管苹果还是玻璃球,都在同一层分支上、用同一套随机采样。
  • 渲染逻辑分叉:只在 createFruitsGeometry() 里根据 type 选材质和是否写 UV。
  • 纹理按需加载:玻璃球不请求任何贴图,避免无意义的 10MB 下载。

第一步:定义配置项

options.js 里扩展 fruits 字段,把「类型无关」和「类型相关」参数放在一起:

this.fruits = {
  enabled: true,
  type: 'apple',          // 'apple' | 'glassBall'

  // 共用:位置与大小
  branchLevel: 3,
  count: 8,
  start: 0.3,
  size: 0.6,
  sizeVariance: 0.2,
  tint: 0xffffff,
  segments: 10,

  // Apple 专用
  shininess: 40,
  capScale: 1.0,

  // GlassBall 专用 — MeshPhysicalMaterial
  transmission: 1.0,
  roughness: 0.05,
  ior: 1.5,
  thickness: 0.35,
  clearcoat: 1.0,
  clearcoatRoughness: 0.03,
};

💡 掘金干货:把两种类型的参数放在同一个 options 对象里,UI 切换时不需要 merge 两套配置,预设 JSON 也能直接序列化。


第二步:在分支上「长」出果实

2.1 挂果时机

tree.js 的分支递归里,当 branch.level === fruits.branchLevel 时调用 generateFruits()。通常和叶子同一层(细枝),视觉上最自然。

2.2 采样算法(和叶子同源)

generateFruits() 复用了叶片的 分层随机采样

generateFruits(sections) {
  const count = this.options.fruits.count;
  const startMin = this.options.fruits.start;
  const heightStep = (1.0 - startMin) / count;
  const angleSlots = this.shuffledIndices(count);

  for (let i = 0; i < count; i++) {
    // 1. 沿分支长度插值得到 origin
    // 2. 用四元数 slerp 得到朝向
    // 3. 径向角 + 抖动,让果实围成一圈而不是排成线
    this.generateFruit(fruitOrigin, fruitOrientation, sectionA.radius);
  }
}

2.3 单个实例数据

每个果实只存三样东西,后面 InstancedMesh 直接用:

this.fruits.instances.push({
  position,   // 从分支点向下悬垂
  rotation,   // 三轴随机,避免「同一个朝向的假球」
  scale: size // 带 sizeVariance 的半径
});

悬垂距离:branchRadius + size * 1.8,让果实「挂在枝上」而不是穿进树干。


第三步:InstancedMesh 批量渲染

100 棵树 × 每树 8 个果实,如果用独立 Mesh 性能会崩。InstancedMesh 是标准答案:

createFruitsGeometry() {
  const geometry = new THREE.SphereGeometry(1, segments, segments);
  const mesh = new THREE.InstancedMesh(geometry, material, instances.length);

  mesh.frustumCulled = false;  // ⚠️ 重要,见下文踩坑
  mesh.renderOrder = isGlassBall ? 3 : 2;

  instances.forEach((instance, index) => {
    dummy.position.copy(instance.position);
    dummy.rotation.copy(instance.rotation);
    dummy.scale.setScalar(instance.scale);
    dummy.updateMatrix();
    mesh.setMatrixAt(index, dummy.matrix);
  });

  mesh.instanceMatrix.needsUpdate = true;
}

踩坑:InstancedMesh 被视锥剔除了

症状:控制台无报错,但 果实完全不显示

原因:实例分散在树的不同位置,几何体本身的 boundingSphere 在原点,相机视锥认为「不在视野内」。

解决:

mesh.frustumCulled = false;
// 或者用所有实例的 AABB 手动设置 boundingSphere

第四步:Apple —— 扫描 Atlas 贴图苹果

4.1 认识贴图布局

苹果用的是 photogrammetry 扫描色贴图 FruitAppleRedWhole001_COL_VAR1_HIRES.jpg

┌─────────────────────────────────┐
│  ● topCap(果柄端)              │
│         ▬ stem(果柄条)         │
│                    ● bottomCap  │
│                      (花萼端)  │
└─────────────────────────────────┘
         背景 = 纯黑

这不是 SphereGeometry 能直接用的等距圆柱 UV,必须手动写 UV。

4.2 标定 Atlas 圆心(别靠肉眼猜)

用脚本对贴图缩略图做像素聚类,得到归一化坐标:

export const AppleAtlas = {
  topCap:    { center: [0.282, 0.720], radius: 0.178 },
  bottomCap: { center: [0.719, 0.277], radius: 0.178 },
  stem:      { center: [0.335, 0.455], size: [0.10, 0.065] },
};

实测比「大概 0.25 / 0.75」精确得多,差 0.03 就会采到黑边。

4.3 核心:半球极投影(Stereographic Projection)

对每个球面顶点,按半球分别投影到对应圆盘:

// 北半球 → topCap(+Y 为果柄端)
if (y >= 0) {
  const sx = x / (1 + y);
  const sz = -z / (1 + y);
  mapped = mapCapUV(sx, sz, atlas.topCap, scale);
}
// 南半球 → bottomCap(-Y 为花萼端)
else {
  const sx = -x / (1 - y);
  const sz = z / (1 - y);
  mapped = mapCapUV(sx, sz, atlas.bottomCap, scale);
}

mapCapUV 里做两件事:

  1. Clamp 到单位圆盘maxRadius = 0.95),防止赤道附近 UV 飞出圆盘。
  2. 映射到 Atlas:u = cx + sx * radius * capScale

4.4 血泪踩坑:红黑斑马纹是怎么来的?

错误写法 后果
radius * scale * 2 赤道 UV 偏移翻倍,飞出圆盘,采到黑色背景
不 Clamp 圆盘 极投影在赤道趋向无穷,必然越界
RepeatWrapping 边缘 UV 重复采样,出现鬼影
在球顶采样 stem 区域 和果柄圆柱冲突,UV 跳变

正确组合:

// 1. 去掉 * 2
u = clamped.sx * cap.radius * scale + cap.center.x;

// 2. 纹理 Clamp
texture.wrapS = THREE.ClampToEdgeWrapping;
texture.wrapT = THREE.ClampToEdgeWrapping;

// 3. 果柄单独用 CylinderGeometry + applyStemUVs()

验证脚本结果:4225 个顶点,0 个落在圆盘外

4.5 果柄:第二个 InstancedMesh

const stemGeometry = new THREE.CylinderGeometry(0.35, 0.5, 1, 6);
applyStemUVs(stemGeometry);  // 映射到 Atlas 的 stem 岛

// 放在球顶 +Y 方向,略微露出表面
stemDummy.position.add(
  up.clone().multiplyScalar(instance.scale * 1.02).applyEuler(instance.rotation)
);

苹果 = 球体 InstancedMesh + 果柄 InstancedMesh,共用一个 colorMap。


第五步:GlassBall —— 纯 Three.js 玻璃球

玻璃球走完全不同的路:不写 UV、不加载贴图、不加果柄

5.1 材质:MeshPhysicalMaterial

export function createGlassBallMaterial({
  tint, transmission, roughness, ior, thickness, clearcoat, clearcoatRoughness,
}) {
  const color = new THREE.Color(tint);

  return new THREE.MeshPhysicalMaterial({
    color,
    metalness: 0,
    roughness,
    transmission,       // 透射,1 = 全透明玻璃
    thickness,          // 玻璃厚度,影响折射深度
    ior,                // 折射率,玻璃约 1.5
    transparent: true,
    clearcoat,          // 清漆高光层
    clearcoatRoughness,
    attenuationColor: color,   // 玻璃染色
    attenuationDistance: 0.6,
    depthWrite: false,         // 透明物体标准写法
  });
}

5.2 和 Apple 的分叉点

const isGlassBall = fruits.type === 'glassBall';

if (isGlassBall) {
  material = createGlassBallMaterial(materialOpts);
} else {
  applyAppleSphereUVs(geometry, materialOpts.capScale);
  material = createAppleBodyMaterial(colorMap, materialOpts);
}

5.3 玻璃球调参指南

参数 效果 推荐范围
Transmission 透明度 0.85 ~ 1.0
Roughness 磨砂感 0 ~ 0.1
IOR 折射强度 1.45 ~ 1.52(玻璃)
Thickness 颜色饱和度 0.2 ~ 0.8
Tint 玻璃颜色 #ffffff 无色,#88ccff 淡蓝
Segments 曲面光滑度 16 ~ 32

玻璃球建议把 Segments 拉到 16+,低面数球体 + 折射会有明显折痕。


第六步:纹理加载策略

export const FruitType = {
  Apple: 'apple',
  GlassBall: 'glassBall',
};

// 只有 apple 有贴图路径
const FruitTexturePaths = {
  apple: {
    color: '/textures/guoshi/apple/FruitAppleRedWhole001_COL_VAR1_HIRES.jpg',
  },
};

// 预加载时按类型判断
if (tree.options.fruits?.type === 'apple') {
  tasks.push(ensureFruitMaps('apple'));
}

玻璃球切换时 assignFruitMaps(null),不会残留苹果贴图。


第七步:UI 面板 —— 一个 Fruits 区搞定

const fruitTypeSelect = createSelect('Type', FruitType, ...);

// Apple 专属控件
const appleFruitControls = [fruitShininessSlider.element, fruitCapScaleSlider.element];

// GlassBall 专属控件
const glassFruitControls = [
  fruitTransmissionSlider.element,
  fruitRoughnessSlider.element,
  fruitIorSlider.element,
  fruitThicknessSlider.element,
  fruitClearcoatSlider.element,
];

function updateFruitTypeControls() {
  const isGlassBall = tree.options.fruits.type === 'glassBall';
  appleFruitControls.forEach(el => el.style.display = isGlassBall ? 'none' : '');
  glassFruitControls.forEach(el => el.style.display = isGlassBall ? '' : 'none');
}

切换类型 → 更新控件可见性 → preloadTreeTextures()tree.generate() 重建。


文件清单(抄作业用)

src/lib/
├── options.js          # fruits 配置项
├── tree.js             # generateFruits / createFruitsGeometry
└── fruitMaterial.js    # UV 映射 + 两种材质工厂

src/app/
├── textures.js         # FruitType、贴图预加载
└── ui.js               # Fruits 面板

完整数据流(一图流)

用户选 Type
    ↓
options.fruits.type = 'apple' | 'glassBall'
    ↓
preloadTreeTextures()  (apple 才加载贴图)
    ↓
tree.generate()
    ↓
generateFruits() → fruits.instances[]createFruitsGeometry()
    ├── apple:    SphereUV + Phong + Stem
    └── glassBall: PhysicalMaterial, 无 Stem
    ↓
InstancedMesh × N 挂到树上

经验总结:7 条可复用的 Three.js 技巧

  1. 扫描贴图 ≠ 普通 UV,Atlas 类资源先分析布局再写映射,别直接 map = texture
  2. 极投影 + 圆盘 Clamp 是「双帽扫描贴图 → 球体」的通用解法。
  3. InstancedMesh 记得关 frustumCulled,或者手动算 boundingSphere。
  4. 透明/玻璃材质设 depthWrite: false,并适当提高 renderOrder
  5. 多种视觉风格共用一套实例管线,只在材质层分叉,代码量减半。
  6. 纹理按需加载,程序化材质不绑贴图路径,启动更快。
  7. UI 控件按类型显隐,比做两个面板维护成本低。

还可以怎么玩?

  • 给 Apple 接上 NRM / GLOSS 扫描图,升级 MeshStandardMaterial
  • 玻璃球加 envMap(HDR 环境贴图),反射更真实
  • 新增 orangepeach 类型:换 Atlas 常量 + 贴图路径即可
  • 果实随风摇摆:在 tree.update(t) 里写 instance matrix 动画

总结

问题 方案
苹果贴图红黑条纹 精确 Atlas 标定 + 极投影 + 圆盘 Clamp + ClampToEdge
100 棵树性能 InstancedMesh 批量渲染
苹果 vs 玻璃球 统一 generateFruits,createFruitsGeometry 处分叉
UI 怎么切换 FruitType 下拉 + 条件显隐控件

从「加个球」到「扫描级苹果 + 物理玻璃球」,核心不是某个神奇 Shader,而是 把生成、实例化、材质三件事解耦

完整代码见:github.com/qdcxj/ez-tr…


如果这篇文章对你有帮助,欢迎点赞收藏。 有问题可以在评论区贴你的 Atlas 布局或截图,一起讨论 UV 映射。


标签:#Three.js #WebGL #程序化生成 #3D #前端可视化 #EZ-Tree #InstancedMesh #PBR

❌