阅读视图

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

three 实现简单机械臂逆运动

很久没写博客了。原来那种事无巨细地铺开技术细节的写法,现在看性价比已经不高了,如何有结构地和 AI 协作反而更重要。这篇就从个人视角回顾一个具体案例。

开始

25 年底机缘巧合需要使用 Unity,我在接近零基础的情况下借助 Coding Agent,快速迁移和扩展了已有知识,把一个角色绕圆柱世界移动的需求拆成了运动合成问题,并通过大量试验做 trade-off,解决了视角抖动和偏移。虽然项目本身只是个半成品,但随着对话轮数增加,工程量还是涨得很快。整个过程在几周内完成,这样的迭代速度让我意识到可以去探索一些更有意思的东西,于是我开始花时间了解机器人的运动学。

机械臂建模

dhLinks.png

一开始最先遇到的问题是,机械臂该怎么定义。这个问题很快收敛到用 DH 参数和改进型 DH 参数(MDH)来描述机械结构。我原本以为这部分会很简单,无非是父子层级关系下的一套坐标系嵌套,但真正实现时还是踩了不少坑。后来通过几套结构反复试验、补上可视化 gizmo、再结合一些 AI 给出的改进方向,才把这部分逐渐理顺。这部分我单独整理到了 wwjll.github.io/three-chamb… ,希望对开始该领域学习的朋友有所帮助。

继续探索

这套东西本身已经很成熟了。我一开始就确定不想用 FABRIK、CCD 这类游戏里常见的巧解,也不想走基于几何关系推导的纯几何解析,因为一旦到了多轴机械臂,复杂度会迅速上升,而且不够通用。这样问题就明确了下来,需要引入 Jacobian matrix 来同时表达旋转和位移。问题到这里虽然变精确了,但也开始发散,因为我并不知道这些量在工程里到底一一对应什么。继续追问之后,AI 默认我已经有了相关背景,开始往工业解法和动力学方程上带。那时我的状态大概就是,“字都认识,意思不清楚”。

问题收敛

在继续扩展之前,我先把问题压到一个能实现的范围里。我想要的只是一个基于位移和旋转的简单可视化求解过程,用 JavaScript 跑在网页里,而不是把 Mujoco 一整套搬进来。于是我给自己定了一个边界:忽略电机参数、编码器噪声、动力学方程这些工业机械臂必须面对的内容,只保留 forward kinematics 和 inverse kinematics,用它们来定义 position error 和 orientation error。这样问题的复杂度就收敛到了一个我当时还能继续推进的层级。

问题定义

把问题压到最小以后,inverse kinematics 的目标就可以写得很直接。设机械臂当前 joint variables 为 qRn\mathbf q \in \mathbb R^n,end effector 当前 pose 记为 x(q)\mathbf x(\mathbf q),目标 pose 记为 xd\mathbf x_d。要做的并不是一次性“解出答案”,而是每一帧根据当前位置和目标位置之间的误差,算出一小步 joint increment Δq\Delta \mathbf q,让机械臂逐步逼近目标。

如果只看微分关系,这件事可以写成:

ΔxJ(q)Δq\Delta \mathbf x \approx \mathbf J(\mathbf q)\,\Delta \mathbf q

其中 J(q)\mathbf J(\mathbf q) 是 Jacobian matrix,它描述了“每个关节动一点点,end effector 的 pose 会怎样变化”。于是 inverse kinematics 的核心就变成了:已知末端误差 e\mathbf e,求一个合适的 Δq\Delta \mathbf q,使得

J(q)Δqe\mathbf J(\mathbf q)\,\Delta \mathbf q \approx \mathbf e

这里的 error vector 通常拆成 position error 和 orientation error 两部分:

e=[eper]R6\mathbf e = \begin{bmatrix} \mathbf e_p \\ \mathbf e_r \end{bmatrix} \in \mathbb R^6

其中 position error 可以直接写成

ep=pdp\mathbf e_p = \mathbf p_d - \mathbf p

orientation error 则不再直接拿 Euler angles 相减,而是更常见地转成 axis-angle 或 rotation vector 来表达。也就是说,我真正需要求解的不是“角度本身”,而是一个能让当前姿态朝目标姿态旋过去的微小旋转量。

如果 J\mathbf J 恰好是方阵且可逆,形式上可以写成

Δq=J1e\Delta \mathbf q = \mathbf J^{-1}\mathbf e

但这在真实问题里几乎不是常态。机械臂经常会遇到下面几种情况:

  • 关节数和任务维度不一致,J\mathbf J 不是方阵
  • 机械臂接近奇异位形,J\mathbf J 虽然看起来能逆,但数值会很不稳定
  • 有些目标本来就不可能被当前结构精确到达,只能求一个最接近的解

所以工程里更常见的是用 pseudoinverse 来做 least-squares 意义下的求解:

Δq=J+e\Delta \mathbf q = \mathbf J^+ \mathbf e

其中 J+\mathbf J^+J\mathbf J 的 Moore-Penrose pseudoinverse。可以把它理解成:在所有可能的 joint increment 里,找一个尽量减小末端误差、同时又不过分夸张的解。工程上通常不会直接停在普通 pseudoinverse 这一步,还会继续引入更稳的形式,但这个项目先走到这里。

整个求解过程可以理解成一个迭代循环:

qk+1=qk+αΔqk\mathbf q_{k+1} = \mathbf q_k + \alpha \,\Delta \mathbf q_k

阶段实现

ik.png

Finite Difference 验证

“每次沿一个很小的方向更新,然后重新计算误差”,顺着这个思路,我先让 Codex 搭了一个只处理位置误差的 demo。它每轮扰动角度 ε\mathbf \varepsilon,反复迭代求解。这个 demo 确实会慢慢向目标靠近,但速度很慢,收敛也不稳定。拆开来看,它做的是下面这件事:

首先,假设第 ii 列 Jacobian 通过 forward finite difference 来近似,那么它通常写成

Jip(q+εei)p(q)ε\mathbf J_i \approx \frac{\mathbf p(\mathbf q + \varepsilon \mathbf e_i) - \mathbf p(\mathbf q)}{\varepsilon}

其中 ei\mathbf e_i 是第 ii 个关节对应的 basis vector。这个式子看起来只是一列一个小公式,但真正落到代码里,含义其实是:

  • 先拿当前关节姿态 q\mathbf q
  • 只扰动第 ii 个关节一个很小的量 ε\varepsilon
  • 重新做一次 forward kinematics,得到新的末端位置
  • 和原来的末端位置做差
  • 最后除以 ε\varepsilon

问题在于,Jacobian 不是只有一列,而是每个关节都要来一次。如果机械臂有 nn 个关节,那么一轮迭代里大致要做:

  • 1 次 forward kinematics,用来求当前误差
  • nn 次扰动后的 forward kinematics,用来拼出整个 Jacobian

也就是说,一轮迭代的成本大约就是

1+n1 + n

次 FK 级别的工作。

写成伪代码:

function solveStep(q, targetPosition, epsilon):
    # 1. 当前姿态先做一次 FK,拿到当前位置和误差
    p0 = forwardKinematics(q)
    error = targetPosition - p0

    # 2. 用数值差分构造 Jacobian
    J = zeroMatrix(3, n)

    for i in 0 .. n-1:
        qPerturbed = copy(q)
        qPerturbed[i] += epsilon

        pi = forwardKinematics(qPerturbed)

        Ji = (pi - p0) / epsilon
        J.setColumn(i, Ji)

    # 3. 解一个关节增量
    dq = pseudoInverse(J) * error

    # 4. 更新关节
    qNext = q + alpha * dq
    return qNext

如果放到 three.js 这类基于节点树的实现里,这个“做一次 FK”往往不只是算几个三角函数,而是要把整条链条上的 local transform 重新传播到 world transform。换句话说,每求一列数值 Jacobian,基本都要重新触发一遍从关节到末端的 world matrix 更新。这也是为什么它在 demo 阶段还能跑,但一旦关节数增多、每帧迭代次数提高,性能就会很快掉下去。

如果这时再叠加一些工程上常见的策略,比如:

  • 每轮失败后 retry
  • 为了避免发散做 backtracking
  • 对多个 candidate step 分别试算误差

那么每试一次候选步长,通常都还要重新评估一次误差,开销会继续往上叠。所以“差分法验证”很适合拿来确认思路,但并不适合作为后续稳定迭代的核心方案。

另外,finite difference 还有一个精度和稳定性上的两难。ε\varepsilon 取大了,导数近似会变粗;ε\varepsilon 取小了,又容易受到浮点误差和姿态表示方式的影响,尤其是在旋转问题里更明显。它既慢,又不够稳,这也是我后来必须继续往 analytic Jacobian 方向走的原因。

Analytic Jacobian 梯度下降

对第 ii 个转动关节,记:

  • joint position 为 pi\mathbf p_i
  • joint axis direction 为 ai\mathbf a_i
  • 末端位置为 p\mathbf p

则 Jacobian 的 linear velocity 部分为:

Jv,i=ai×(ppi) \mathbf J_{v,i} = \mathbf a_i \times (\mathbf p - \mathbf p_i)

angular velocity 部分为:

Jω,i=ai \mathbf J_{\omega,i} = \mathbf a_i

所以第 ii 列 Jacobian 写成:

Ji=[Jv,iJω,i]=[ai×(ppi)ai] \mathbf J_i = \begin{bmatrix} \mathbf J_{v,i} \\ \mathbf J_{\omega,i} \end{bmatrix} = \begin{bmatrix} \mathbf a_i \times (\mathbf p - \mathbf p_i) \\ \mathbf a_i \end{bmatrix}

写成伪代码,会更容易看出为什么它比 finite difference 便宜:

function solveStepAnalytic(q, targetPosition):
    # 1. 先做一次 FK,拿到整条链的世界坐标信息
    updateWorldTransforms(q)

    pEnd = getEndEffectorPosition()
    error = targetPosition - pEnd

    # 2. 直接用关节轴和末端位置构造 Jacobian
    J = zeroMatrix(3, n)

    for i in 0 .. n-1:
        pJoint = getJointWorldPosition(i)
        axis = getJointWorldAxis(i)

        Ji = cross(axis, pEnd - pJoint)
        J.setColumn(i, Ji)

    # 3. 解一个关节增量
    dq = pseudoInverse(J) * error

    # 4. 更新关节
    qNext = q + alpha * dq
    return qNext

这里的 qNext 表示这一轮更新之后的新 joint state,也就是把当前 joint variables q\mathbf q 沿着本轮算出来的 increment Δq\Delta \mathbf q 推进一步:

qk+1=qk+αΔqk\mathbf q_{k+1} = \mathbf q_k + \alpha \Delta \mathbf q_k

finite difference 和 analytic Jacobian 最大的区别,不在最后那一步更新公式,而在 Jacobian 的构造方式:

  • finite difference 是“每扰动一个关节,就重新做一次 FK,再拿结果做差”
  • analytic Jacobian 是“先做一次 FK,拿到所有关节的 world position 和 axis direction,再直接算出每一列”

所以每轮迭代的开销大致分别是:

数值差分:

1+n 次 FK1 + n \text{ 次 FK}

analytic Jacobian:

1 次 FK+O(n) 个向量运算1 \text{ 次 FK} + O(n) \text{ 个向量运算}

前者把大部分时间花在重复刷新整条链的 world transform,后者则把这部分工作压缩到一次,然后只做叉乘、减法和矩阵拼装。对于 three.js 这种场景,这个差距会很直接地反映在每帧迭代次数和交互流畅度上。

到这里,这个简单的算法已经能比较流畅地工作了。点击实验 wwjll.github.io/three-chamb…

加入 Physics Engine

ikPick.png

后来发现官方在用 rapier 这个相对轻量的 physics engine,于是我也把它引了进来,设计了一个带滑轨的夹爪,并把各个部分做了一定程度的解耦。动画过程也被拆成几个阶段:夹爪下降、夹取、抬升、移动到目标位、释放。

为了保证渲染性能,我把整个仓库里的例子都改成了 lazy rendering,也就是只有显式触发 render request 时才会真正渲染,性能提升很明显。

但 physics engine 和 grasp flow 一旦加进来,复杂度也立刻上去了,state control 多了很多。为了让过程更可控,又补了不少设定:

  • 预设了 end effector 垂直向下的 zero pose
  • 抓取过程的 physics contact state 解除
  • 增加了 joint limits,限位外的无法到达
  • 增加了 axis sign 来方便控制不同关节旋转正负号
  • position、quaternion pose 的插值
  • 可视化调节 controls

这部分可以在 wwjll.github.io/three-chamb… 里实验,需要先点击 “spawn cubes” 生成拾取方块。

最后的感受是,复杂度上来之后,真正关键的不是多写几轮代码,而是先把整体结构想清楚。遇到未知步骤时要停下来,重新拆解和设计,而不是顺着惯性往下堆实现。和 AI 协作也是一样,前提仍然是自己对问题边界、模块关系和验证方式有基本判断。

前端3D·Three.js一学就会系列:第二 画线

各位前端伙伴们,大家好,我是阿峰。最近开始入坑前端3D建站,跟大家一起慢慢深入three.js做网站3D。

今天给大家讲下three.js 画线


一、省略部分

官网,介绍,以及引入库,参看文章片头系列文章:01 第一个3D网站

二、使用方法

创建一个场景

const scene = new THREE.Scene();

创建一个透视摄像机

const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 );
camera.position.set( 0, 0, 100 );
camera.lookAt( 0, 0, 0 );

知识点: camera.position.set():三个参数固定透视摄像机的位置 camera.lookAt():三个参数固定透视摄像机的拍摄方向

将渲染器添加到页面上

const renderer = new THREE.WebGLRenderer();
renderer.setSize( window.innerWidth, window.innerHeight );
document.body.appendChild( renderer.domElement );

创建一个线条

const points = [];
points.push(new THREE.Vector3( - 10, 0, 0 ) );
points.push( new THREE.Vector3( 0, 10, 0 ) );
points.push( new THREE.Vector3( 10, 0, 0 ) );
const geometry = new THREE.BufferGeometry().setFromPoints( points );
const material = new THREE.LineBasicMaterial( { color: 0x0000ff } );

const line = new THREE.Line( geometry, material );
scene.add( line );

知识点: Vector3:三维向量x、y和z 代表位置 BufferGeometry: 是面片、线或点几何体的有效表述 setFromPoints:设置数据来源 LineBasicMaterial:线条材质:可定义属性 color颜色,linewidth线宽等参考LineBasicMaterial 【扩展】 LineDashedMaterial:与LineBasicMaterial同样是线条材质:可定义属性 color颜色,linewidth线宽等参考LineDashedMaterial

渲染场景

function animate() {
requestAnimationFrame( animate );
renderer.render( scene, camera );
}
animate();

requestAnimationFrame有很多的优点。最重要的一点或许就是当用户切换到其它的标签页时,它会暂停,因此不会浪费用户宝贵的处理器资源,也不会损耗电池的使用寿命。

线条动起来

function animate() {
requestAnimationFrame( animate );
// 旋转方向,及大小
cube.rotation.x += 0.01;
cube.rotation.y += 0.01;

renderer.render( scene, camera );
};

animate();

完整代码(实例)

<html>
<head>
<meta charset="utf-8">
<title>My first three.js app</title>
<style>
body { margin: 0; }
</style>
</head>
<body>
<script src="./three.js"></script>
<!-- <script src="https://threejs.org/build/three.js"></script> -->
<script>
// 创建一个场景
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 );
camera.position.set( 0, 0, 100 );
camera.lookAt( 0, 0, 0 );

// 展示
const renderer = new THREE.WebGLRenderer();
renderer.setSize( window.innerWidth, window.innerHeight );
document.body.appendChild( renderer.domElement );
// 创建一条线
const points = [];
points.push( new THREE.Vector3( - 10, 0, 0 ) );
points.push( new THREE.Vector3( 0, 10, 0 ) );
points.push( new THREE.Vector3( 10, 0, 0 ) );
const geometry = new THREE.BufferGeometry().setFromPoints( points );
const material = new THREE.LineBasicMaterial( { color: 0x0000ff } );

const line = new THREE.Line( geometry, material );
scene.add( line );

function animate() {
requestAnimationFrame( animate );

line.rotation.x += 0.01;
line.rotation.y += 0.01;

renderer.render( scene, camera );
};

animate();
</script>
</body>
</html>

效果

在这里插入图片描述

总结

以上就是今天要讲的内容,本文仅仅简单介绍了three.js的使用,而three.js提供了非常多的3D显示功能,后续文章,我将带大家慢慢深入了解。


如果这篇这篇文章对您有帮助?关注、点赞、收藏,三连支持一下。 有疑问或想法?评论区见。 我们下期再见。

前端3D·Three.js一学就会系列: 第一个3D网站

各位前端伙伴们,大家好,我是阿峰。最近开始入坑前端3D建站,跟大家一起慢慢深入three.js做网站3D。


一、Three.js是什么?

官网

threejs.org/

three.js是JavaScript编写的WebGL第三方库。提供了非常多的3D显示功能。

官网示例效果尝鲜

请添加图片描述

二、使用步骤

1.引入three.js库

在线库

<script src="https://threejs.org/build/three.js"></script>

离线可以去官网threejs.org/docs/index.… 下载复制到项目所在的目录下 在这里插入图片描述

<script src="./three.js"></script>

2.使用方法

创建一个场景

const scene = new THREE.Scene();

创建一个透视摄像机

const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 );
camera.position.z = 5;

参数:视野角度(FOV)、长宽比(aspect ratio)、近截面(near)和远截面(far) camera.position.z:透视摄像机位置

将渲染器添加到页面上

const renderer = new THREE.WebGLRenderer();
renderer.setSize( window.innerWidth, window.innerHeight );
document.body.appendChild( renderer.domElement );

创建一个立方体

const geometry = new THREE.BoxGeometry( 1, 1, 1 );
const material = new THREE.MeshBasicMaterial( { color: 0x00ff00 } );
const cube = new THREE.Mesh( geometry, material );
scene.add( cube );

BoxGeometry:立方体,参数为所有顶点和面 MeshBasicMaterial:材质,将应用到对象上,color设置了对象的颜色 Mesh:网格,几何体和几何体材质,作用 scene.add:添加到场景上

渲染场景

function animate() {
requestAnimationFrame( animate );
renderer.render( scene, camera );
}
animate();

requestAnimationFrame有很多的优点。最重要的一点或许就是当用户切换到其它的标签页时,它会暂停,因此不会浪费用户宝贵的处理器资源,也不会损耗电池的使用寿命。

立方体动起来

function animate() {
requestAnimationFrame( animate );
// 旋转方向,及大小
cube.rotation.x += 0.01;
cube.rotation.y += 0.01;

renderer.render( scene, camera );
};

animate();

完整代码(实例)

<html>
<head>
<meta charset="utf-8">
<title>My first three.js app</title>
<style>
body { margin: 0; }
</style>
</head>
<body>
<script src="./three.js"></script>
<!-- <script src="https://threejs.org/build/three.js"></script> -->
<script>
// 创建一个场景
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 );
// 展示
const renderer = new THREE.WebGLRenderer();
renderer.setSize( window.innerWidth, window.innerHeight );
document.body.appendChild( renderer.domElement );
// 创建一个立方体
const geometry = new THREE.BoxGeometry( 1, 1, 1 );
const material = new THREE.MeshBasicMaterial( { color: 0x00ff00 } );
const cube = new THREE.Mesh( geometry, material );
scene.add( cube );

camera.position.z = 5;

function animate() {
requestAnimationFrame( animate );

cube.rotation.x += 0.01;
cube.rotation.y += 0.01;

renderer.render( scene, camera );
};

animate();
</script>
</body>
</html>

效果

在这里插入图片描述

总结

以上就是今天要讲的内容,本文仅仅简单介绍了three.js的使用,而three.js提供了非常多的3D显示功能,后续文章,我将带大家慢慢深入了解。


如果这篇这篇文章对您有帮助?关注、点赞、收藏,三连支持一下。 有疑问或想法?评论区见。 我们下期再见。

Three.js 场景完全入门指南:让你的 3D 场景不在乱成一团

场景图到底是什么?一句话说清楚

场景图 = 你 3D 世界里的「家族族谱」

你在 Three.js 里创建的每一个物体——立方体、球体、灯光、相机——它们不是孤立存在的,而是像家族成员一样,有爸爸、有儿子、有孙子,形成一个树状的层级结构。

这个结构,就叫场景图。

想象一下你在玩乐高:

  • 你先搭了一个车身(父节点)
  • 然后在车身上装了 4 个轮子(子节点)
  • 每个轮子上又装了轮毂装饰(孙节点)

当你拿起整个车身移动时,轮子和轮毂会自动跟着动。你不需要一个一个去移动它们。

这就是场景图的核心逻辑:父节点动,子节点自动跟着动。

02-concept-scenegraph.png

为什么需要场景图?

假设你要做一个太阳系模型:

  • 太阳在中心
  • 地球绕着太阳转
  • 月球绕着地球转

如果没有场景图,你得这么写:

// 每一帧都要手动计算位置
function animate() {
  // 地球绕太阳转
  earth.position.x = Math.cos(time) * 10;
  earth.position.z = Math.sin(time) * 10;

  // 月球绕地球转,还要加上地球的位置
  moon.position.x = earth.position.x + Math.cos(time * 2) * 2;
  moon.position.z = earth.position.z + Math.sin(time * 2) * 2;

  // 如果再加个火星、木星、土星...
  // 你的代码会变成一坨屎
}

有了场景图,你只需要:

// 把月球设为地球的子节点
earth.add(moon);

// 把地球设为太阳的子节点
sun.add(earth);

// 每一帧只需要旋转父节点
function animate() {
  sun.rotation.y += 0.01;  // 太阳自转
  earth.rotation.y += 0.02; // 地球自转,月球自动跟着转
}

场景图让你从「手动计算每个物体的绝对位置」,变成「只管理父子关系,让系统自动计算」。


场景图的三大核心规则

规则 1:每个物体都有自己的「局部坐标系」

这是最容易搞混的地方。

在 Three.js 里,每个物体的 positionrotationscale 都是相对于它的父节点的,不是相对于整个世界的。

举个例子:

const car = new THREE.Group(); // 汽车
const wheel = new THREE.Mesh(wheelGeometry, wheelMaterial); // 轮子

wheel.position.x = 2; // 轮子在汽车坐标系里,向右偏移 2 个单位
car.add(wheel);

car.position.x = 10; // 汽车在世界坐标系里,向右移动 10 个单位

此时,轮子在世界坐标系里的实际位置是 10 + 2 = 12

但你在代码里看到的 wheel.position.x 还是 2,因为它记录的是相对于父节点(汽车)的位置

这就像你在高铁上走动:

  • 你相对于车厢的位置是「第 5 排座位」(局部坐标)
  • 但你相对于地球的位置,是「第 5 排座位 + 高铁的位置」(世界坐标)

规则 2:父节点的变换会「传递」给所有子节点

这是场景图最强大的地方。

当你旋转、缩放、移动一个父节点时,它的所有子节点、孙节点、曾孙节点……都会跟着变。

const robot = new THREE.Group();
const body = new THREE.Mesh(bodyGeometry, bodyMaterial);
const leftArm = new THREE.Mesh(armGeometry, armMaterial);
const rightArm = new THREE.Mesh(armGeometry, armMaterial);

robot.add(body);
body.add(leftArm);
body.add(rightArm);

// 旋转机器人,整个机器人(包括身体和手臂)都会转
robot.rotation.y = Math.PI / 4;

// 旋转身体,手臂会跟着转,但机器人的腿不会动
body.rotation.x = Math.PI / 6;

这就像你转身:

  • 你的头、手、脚都会跟着转(子节点跟随父节点)
  • 但你手上拿的手机屏幕方向不会变(子节点保持自己的局部旋转)

规则 3:Scene 是所有节点的「根节点」

在 Three.js 里,Scene 就是那个最顶层的「祖宗节点」。

所有你想渲染出来的东西,都必须直接或间接地添加到 Scene 里。

const scene = new THREE.Scene();

// 方式 1:直接添加到场景
scene.add(cube);

// 方式 2:添加到某个组,再把组添加到场景
const group = new THREE.Group();
group.add(cube);
scene.add(group);

Scene 就像一个舞台:

  • 只有站在舞台上的演员(或演员团队)才能被观众(相机)看到
  • 你在后台准备的道具(没 add 到 scene 的物体),观众看不见

···

真实场景:用场景图管理一辆汽车

假设你要做一个可交互的汽车模型:

  • 汽车可以前进、后退、转弯
  • 4 个轮子要跟着车身动
  • 轮子转弯时要旋转
  • 车门可以单独打开

没有场景图的噩梦写法:

// 每次移动汽车,你要手动更新 5 个物体的位置
function moveCar(distance) {
  carBody.position.z += distance;
  wheel1.position.z += distance;
  wheel2.position.z += distance;
  wheel3.position.z += distance;
  wheel4.position.z += distance;
  door.position.z += distance;
}

// 转弯时,你要手动计算每个轮子的新位置
function turnCar(angle) {
  // 这里要写一堆三角函数...
  // 而且很容易算错
}

用场景图的优雅写法:

// 1. 创建层级结构
const car = new THREE.Group(); // 汽车根节点
const body = new THREE.Mesh(bodyGeometry, bodyMaterial); // 车身
const door = new THREE.Mesh(doorGeometry, doorMaterial); // 车门

const wheel1 = new THREE.Mesh(wheelGeometry, wheelMaterial);
const wheel2 = new THREE.Mesh(wheelGeometry, wheelMaterial);
const wheel3 = new THREE.Mesh(wheelGeometry, wheelMaterial);
const wheel4 = new THREE.Mesh(wheelGeometry, wheelMaterial);

// 2. 建立父子关系
car.add(body);
body.add(door); // 车门是车身的子节点
body.add(wheel1);
body.add(wheel2);
body.add(wheel3);
body.add(wheel4);

scene.add(car); // 整辆车添加到场景

// 3. 设置轮子的局部位置(相对于车身)
wheel1.position.set(-1, -0.5, 1.5);  // 左前轮
wheel2.position.set(1, -0.5, 1.5);   // 右前轮
wheel3.position.set(-1, -0.5, -1.5); // 左后轮
wheel4.position.set(1, -0.5, -1.5);  // 右后轮

// 4. 移动汽车,只需要操作根节点
function moveCar(distance) {
  car.position.z += distance; // 一行代码,整辆车都动了
}

// 5. 转弯,也只需要操作根节点
function turnCar(angle) {
  car.rotation.y += angle; // 一行代码,整辆车都转了
}

// 6. 打开车门,只操作车门节点
function openDoor() {
  door.rotation.y = Math.PI / 3; // 车门绕自己的轴旋转
}

// 7. 轮子转动,只操作轮子节点
function rotateWheels(speed) {
  wheel1.rotation.x += speed;
  wheel2.rotation.x += speed;
  wheel3.rotation.x += speed;
  wheel4.rotation.x += speed;
}

场景图让你的代码从「管理 100 个物体的绝对位置」,变成「管理 10 个父子关系」。

代码量少了 90%,bug 也少了 90%。

···

进阶技巧:Group 是你最好的朋友

Three.js 提供了一个专门用来组织场景图的工具:THREE.Group()

它就是一个「空节点」,自己不渲染任何东西,但可以作为其他物体的容器。

什么时候用 Group?

  1. 逻辑分组:把相关的物体放在一起

    const furniture = new THREE.Group();
    furniture.add(table);
    furniture.add(chair);
    furniture.add(lamp);
    
    // 一次性移动所有家具
    furniture.position.x = 5;
    
  2. 动画控制:需要整体旋转或移动时

    const solarSystem = new THREE.Group();
    solarSystem.add(sun);
    solarSystem.add(earth);
    solarSystem.add(mars);
    
    // 整个太阳系旋转
    solarSystem.rotation.y += 0.01;
    
  3. 坐标系转换:需要改变物体的旋转中心时

    // 默认情况下,物体绕自己的中心旋转
    // 如果你想让它绕另一个点旋转,可以用 Group
    
    const pivot = new THREE.Group();
    pivot.add(cube);
    cube.position.x = 5; // 立方体偏离 pivot 中心
    
    pivot.rotation.y += 0.01; // 立方体绕 pivot 中心旋转(公转)
    cube.rotation.y += 0.02;  // 立方体绕自己中心旋转(自转)
    

Group 就像乐高的底板:

  • 你可以在底板上搭建复杂的结构
  • 然后拿起整个底板移动,所有东西都跟着动
  • 底板本身不占空间,只是一个「组织工具」

···

常见坑点:为什么我的物体位置不对?

坑点 1:忘记父节点的变换会累积

const parent = new THREE.Group();
parent.scale.set(2, 2, 2); // 父节点放大 2 倍

const child = new THREE.Mesh(geometry, material);
child.scale.set(2, 2, 2); // 子节点也放大 2 倍
parent.add(child);

// 结果:子节点实际被放大了 2 × 2 = 4 倍!

解决方法:

  • 要么只在父节点设置缩放
  • 要么在子节点用 1 / parent.scale.x 来抵消

坑点 2:直接修改 world position 不生效

const child = new THREE.Mesh(geometry, material);
parent.add(child);

// ❌ 错误:直接设置世界坐标不会生效
child.position.set(10, 0, 0); // 这是局部坐标!

// ✅ 正确:如果要设置世界坐标,需要先转换
const worldPos = new THREE.Vector3(10, 0, 0);
child.parent.worldToLocal(worldPos);
child.position.copy(worldPos);

坑点 3:移除节点时忘记清理引用

// ❌ 错误:只从场景移除,但父子关系还在
scene.remove(child);

// ✅ 正确:从父节点移除
parent.remove(child);

// ✅ 更好:彻底清理
parent.remove(child);
child.geometry.dispose();
child.material.dispose();

核心代码与完整示例:      my-three-app

总结

如果你喜欢本教程,记得点赞+收藏!关注我获取更多Three.js开发干货

❌