Vue3+ThreeJS机械臂控制与预览:从关节层级到运动学实现
简介:基于 Vue3 与 Three.js 的机械臂控制与预览项目源码,面向毕业设计、课程设计与前端三维可视化开发。项目采用 Vue3 + Element Plus 搭建界面,Three.js 完成 3D 场景渲染;核心 initRobot 方法从 D1 关节开始逐层嵌套,构建出 D1~D5 共 5 个活动关节和 B1~B4 共 4 个力臂,并通过 setRobotRotation 对外提供角度控制、setControlsEnabled 对外提供视角开关控制。压缩包共 18 个文件,包含 5 个 Vue 组件、5 个 JavaScript 脚本、3 个 JSON 配置、开发文档及静态资源,整体约 30KB,目录结构清晰,便于定位与移植。资源附带开发文档和代码解析,有助于理解机械臂分层建模、关节父子嵌套和外部调用接口的设计思路,适合在毕业设计或课程设计中直接复用,也可在其基础上扩展交互功能或接入真实设备控制。目前已有 267 人学习下载,源码经过严格测试,可放心参考并结合自身场景延伸使用。
1. 基于 Vue3+ThreeJS 的机械臂控制预览,不是「加载个模型」这么简单
很多人以为机械臂预览就是把 GLTF 模型丢进 ThreeJS 场景,再放几个 Vue3 滑块转一转角度;等做到一半才发现,屏幕上的连杆乱转,滑块和真实关节根本对不上。基于 Vue3+ThreeJS 做机械臂控制和预览,核心工作是把关节角、父子层级、正逆运动学和轨迹插值组织成一条可验证的控制链路:Vue3 的响应式负责管 UI 状态,ThreeJS 只管矩阵与渲染,两者通过 watch 手动同步。下面直接按最小工程、运动学核心、源码工程与开发文档、以及预览与实机同步的顺序讲,新手能跟完整个交互,熟手也能看到限位、万向锁和 WebSocket 同步这几个容易被忽略的边界。
2. 用 Vue3+ThreeJS 搭机械臂预览场景的最小工程
2.1 为什么把 ThreeJS 场景踢出 Vue 响应式
初学时容易把 scene、camera、mesh 全部放进 reactive({}) 里,然后发现拖动滑块时页面明显卡顿,甚至偶尔报 Cannot read properties of undefined 。Vue3 的 Proxy 代理对 THREE.Vector3 这类高频更新的对象开销很大,而且 ThreeJS 内部的 matrixWorld 会在每一帧持续重算,一旦被响应式跟踪,任何矩阵写入都可能触发组件更新,预览帧率直接掉一半。
我的做法是:场景相关对象用普通变量或者 shallowRef ,只把关节角度、速度、播放状态放进 Pinia store;视图层通过 watch 把变更手动同步到 ThreeJS。这个边界比任何自动同步方案都稳,因为 ThreeJS 场景的更新频率由 requestAnimationFrame 决定,不需要也不应该被 Vue 的调度器干预。
import { shallowRef } from 'vue'
import * as THREE from 'three'
const scene = shallowRef(null)
// 初始化时赋值,replace 之后 Vue 不会深层追踪 scene 内部对象
scene.value = new THREE.Scene()
shallowRef 只跟踪 .value 的替换,不跟踪 scene.value.add() 这类方法产生的内部变化,对 ThreeJS 这种庞大对象图来说是最合适的响应式容器。如果你确实需要把某个 mesh 结构展示在页面上,就把它转成普通 JSON 再放进 reactive,不要直接放 THREE 对象。
2.2 初始化场景、相机、渲染器与动画循环
最小预览工程只需要五个对象:场景、透视相机、WebGL 渲染器、轨道控制器和一支光源。下面是可直接跑的初始化代码。
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import { onMounted, onBeforeUnmount, ref } from 'vue'
const containerRef = ref(null)
let renderer, scene, camera, controls
let animationId = 0
function initScene() {
const container = containerRef.value
scene = new THREE.Scene()
scene.background = new THREE.Color(0x1a1a2e)
camera = new THREE.PerspectiveCamera(
45,
container.clientWidth / container.clientHeight,
0.1,
1000
)
camera.position.set(8, 6, 10)
camera.lookAt(0, 1, 0)
renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.setSize(container.clientWidth, container.clientHeight)
container.appendChild(renderer.domElement)
controls = new OrbitControls(camera, renderer.domElement)
controls.target.set(0, 1, 0)
controls.update()
scene.add(new THREE.GridHelper(20, 20, 0x444466, 0x333355))
const light = new THREE.DirectionalLight(0xffffff, 3)
light.position.set(5, 10, 7)
scene.add(light)
scene.add(new THREE.AmbientLight(0xffffff, 0.6))
}
function renderLoop() {
animationId = requestAnimationFrame(renderLoop)
controls.update()
renderer.render(scene, camera)
}
onMounted(() => {
initScene()
renderLoop()
})
onBeforeUnmount(() => {
cancelAnimationFrame(animationId)
renderer.dispose()
})
这段代码固定了预览场景的四个基础对象: PerspectiveCamera 用 45 度视角,适合观察两米以内的六轴机械臂; OrbitControls 提供鼠标旋转和平移,方便从侧面检查连杆干涉; GridHelper 和双光源建立空间参考系。 devicePixelRatio 限制为 2 是为了避免高分屏把像素量撑到四倍,对机械臂这种大量细长杆件很关键。
代码里没有绑定任何 Vue 响应式数据, renderer 每帧重绘时 Vue 不会介入,这是预览流畅的前提。 onBeforeUnmount 里只调用了 renderer.dispose() ,如果场景里有自定义 geometry 和 material,还需要逐个 dispose,这一节在第四章代码解析时单独展开。
2.3 用几何体搭 6 轴机械臂骨架
我不建议一上来就加载 GLTF,先用盒体和圆柱体把 6 个关节的父子关系搭出来,运动学正确后再换外壳。常见六轴机械臂的关节命名沿用 UR 风格:base、shoulder、elbow、wrist1、wrist2、flange。在 ThreeJS 里每个关节对应一个 THREE.Group ,下一级关节挂在上一级 Group 的末端位置。
const JOINT_DEFS = [
{ name: 'joint1', axis: 'y' },
{ name: 'joint2', axis: 'z' },
{ name: 'joint3', axis: 'z' },
{ name: 'joint4', axis: 'y' },
{ name: 'joint5', axis: 'z' },
{ name: 'joint6', axis: 'y' }
]
function buildLink(parent, size, offsetY) {
const geo = new THREE.BoxGeometry(...size)
const mat = new THREE.MeshStandardMaterial({ color: 0x55aaff })
const mesh = new THREE.Mesh(geo, mat)
mesh.position.y = offsetY
parent.add(mesh)
return mesh
}
function buildArm() {
const root = new THREE.Group()
const groupMap = new Map()
JOINT_DEFS.forEach((def, index) => {
const group = new THREE.Group()
group.name = def.name
groupMap.set(def.name, group)
// 父子挂载:joint2 挂在 joint1 末端,joint3 挂在 joint2 末端
if (index === 0) {
root.add(group)
} else {
const parentName = JOINT_DEFS[index - 1].name
groupMap.get(parentName).add(group)
}
// 每个关节组内部放一个可视化的连杆盒体
buildLink(group, [0.5, 0.6, 0.5], index === 0 ? 0.3 : 0.3)
group.position.y = index === 0 ? 0 : 0.6
})
return { root, groupMap }
}
代码里的核心是父子挂载: joint2 成为 joint1 的子对象, joint3 再挂到 joint2 上,旋转 joint1 时整条手臂一起转,旋转 joint3 时只有肘部以下动,这正好符合机械臂的传动链。 group.position.y 表示连杆长度偏移,先用固定值 0.6 占位,后面按真实尺寸修改。
| 关节 | Group 名 | 旋转轴 | 连接偏移 | 控制范围(度) |
|---|---|---|---|---|
| 1 基座回转 | joint1 | y | 底座高度 | -180 ~ 180 |
| 2 肩部俯仰 | joint2 | z | 大臂长度 | -180 ~ 45 |
| 3 肘部俯仰 | joint3 | z | 小臂长度 | -90 ~ 90 |
| 4 腕部回转 | joint4 | y | 腕部长度 | -180 ~ 180 |
| 5 腕部俯仰 | joint5 | z | 手腕短节 | -120 ~ 120 |
| 6 法兰回转 | joint6 | y | 末端短节 | -180 ~ 180 |
提示:偏移值请按真实连杆图纸测量,不要直接照抄机器人厂商的 DH 参数表,因为 DH 表的坐标系原点和 ThreeJS Group 的挂载点通常不重合。
3. 机械臂控制核心:ThreeJS 关节层级与正逆运动学
3.1 从滑块到关节角:Vue3 的 state 只管数值
机械臂控制界面的滑块、数字输入、轨迹播放,本质都是修改 6 个数字。在 Vue3 里我把这些数字放在 Pinia store,而不是散落在组件 props,因为预览组件、末端坐标显示、轨迹面板都要读同一份状态。
import { defineStore } from 'pinia'
export const useArmStore = defineStore('arm', {
state: () => ({
jointAngles: [0, 0, 0, 0, 0, 0],
speed: 1,
playing: false
}),
actions: {
setJoint(index, degree) {
const low = this.limits[index][0]
const high = this.limits[index][1]
this.jointAngles[index] = Math.max(low, Math.min(degree, high))
}
}
})
jointAngles 是唯一数据源。我用角度制而不是弧度制,为的是让滑块、日志、开发文档里的参数对得上;只有写入 THREE.Group.rotation 时才用 THREE.MathUtils.degToRad 转换。限位写进 setJoint ,比在滑块组件里写 min/max 更可靠,因为轨迹回放和外部 WebSocket 指令都走同一个入口,不会绕过限位检查。
3.2 把角度同步到 ThreeJS:watch 只触发一次
在场景组件里监听 store,将 6 个关节角度同步到对应 Group。这里要小心 watch 的触发次数。
import { watch } from 'vue'
import * as THREE from 'three'
import { useArmStore } from '@/stores/arm'
const armStore = useArmStore()
const groupMap = new Map() // 上一节 buildArm 返回的 groupMap
watch(
() => [...armStore.jointAngles],
(angles) => {
JOINT_DEFS.forEach((def, index) => {
const group = groupMap.get(def.name)
group.rotation[def.axis] = THREE.MathUtils.degToRad(angles[index])
})
}
)
watch 的第一个参数用 [...armStore.jointAngles] 返回一个新数组,Vue 会比较数组内容变化;如果直接传 armStore.jointAngles 引用,只有整个数组被替换才会触发。每次更新只写 rotation 对应轴,其他轴保持原值,避免频繁写完整 transform。这里不写 deep: true ,因为深层追踪对一个 6 元素数组没有意义,反而增加代理开销。
关节轴方向与 DH 参数的对应关系可以查下面这张表:
| 关节 | ThreeJS 旋转轴 | DH 参数对应 | 说明 |
|---|---|---|---|
| joint1 | y | θ1 | 基座回转,绕重力方向 |
| joint2 | z | θ2 | 肩部俯仰,受底座几何限制 |
| joint3 | z | θ3 | 肘部俯仰 |
| joint4 | y | θ4 | 腕部回转 |
| joint5 | z | θ5 | 腕部俯仰 |
| joint6 | y | θ6 | 法兰回转 |
如果你的机械臂不是这个布局,把 JOINT_DEFS 里的 axis 换掉即可,滑块、限位、末端位姿计算全部跟着改,不需要动组件模板。
3.3 正运动学:读取末端位姿而不是猜坐标
机械臂控制里末端在哪是最常被问到的需求。ThreeJS 的 getWorldPosition 和 getWorldQuaternion 能直接给出经过所有父级变换后的结果,但前提是调用前先 updateWorldMatrix 。
function getFlangePose() {
const flange = groupMap.get('joint6')
flange.updateWorldMatrix(true, false)
const position = new THREE.Vector3()
flange.getWorldPosition(position)
const quaternion = new THREE.Quaternion()
flange.getWorldQuaternion(quaternion)
const euler = new THREE.Euler().setFromQuaternion(quaternion, 'XYZ')
return {
x: position.x.toFixed(3),
y: position.y.toFixed(3),
z: position.z.toFixed(3),
rx: THREE.MathUtils.radToDeg(euler.x).toFixed(2),
ry: THREE.MathUtils.radToDeg(euler.y).toFixed(2),
rz: THREE.MathUtils.radToDeg(euler.z).toFixed(2)
}
}
updateWorldMatrix(true, false) 的第一个参数表示递归更新子级,第二个参数表示不强制更新父级。如果父级链还没更新完就直接 getWorldPosition ,可能拿到上一帧的旧矩阵,所以我在所有读位姿的入口都先调用这一行。末端姿态用欧拉角返回,方便在 UI 面板上显示;轨迹记录时直接把这 6 个值 push 进数组即可。
3.4 逆运动学:用 CCD 迭代让末端去够目标点
真实机械臂系统一般把逆解放在后端,用 ROS 加 MoveIt 或者厂商 SDK;但纯前端预览也需要「点一下目标点,机械臂自己动过去」的交互。我用 CCD(Cyclic Coordinate Descent)做简化逆解:从末端关节往基座方向逐个尝试旋转,让末端方向向量向目标方向靠拢。
function solveIK(targetPos, jointDefs, maxIterations = 40) {
const flange = groupMap.get('joint6')
const endPos = new THREE.Vector3()
const jointPos = new THREE.Vector3()
const localAxis = new THREE.Vector3()
const worldQuat = new THREE.Quaternion()
for (let iter = 0; iter < maxIterations; iter++) {
flange.updateWorldMatrix(true, false)
flange.getWorldPosition(endPos)
const err = endPos.distanceTo(targetPos)
if (err < 0.01) break
for (let i = jointDefs.length - 1; i >= 0; i--) {
const group = groupMap.get(jointDefs[i].name)
group.updateWorldMatrix(true, false)
group.getWorldPosition(jointPos)
const toEnd = endPos.clone().sub(jointPos).normalize()
const toTarget = targetPos.clone().sub(jointPos).normalize()
const cosAngle = THREE.MathUtils.clamp(toEnd.dot(toTarget), -1, 1)
const angle = Math.acos(cosAngle)
if (angle < 0.001) continue
localAxis.set(0, 1, 0)
if (jointDefs[i].axis === 'z') localAxis.set(0, 0, 1)
group.getWorldQuaternion(worldQuat)
const worldAxis = localAxis.clone().applyQuaternion(worldQuat)
group.rotateOnWorldAxis(worldAxis, Math.min(angle * 0.5, 0.1))
flange.updateWorldMatrix(true, false)
flange.getWorldPosition(endPos)
}
}
return [...armStore.jointAngles]
}
CCD 的收敛逻辑不复杂:每次迭代先算末端与目标点的距离,再倒序调整 6 个关节; angle * 0.5 是阻尼系数,防止多个关节同时逼近时来回震荡; maxIterations=40 对六轴预览足够,再多迭代视觉上已经没有差别。注意 worldAxis 是把关节本地的旋转轴变换到世界坐标系,否则 rotateOnWorldAxis 会绕错方向。真实项目里还需要加关节限位、奇异规避和碰撞检测,前端 CCD 只用于判断目标点是否可达,不作为实机控制依据。
4. 机械臂控制源码工程与开发文档:让队友能接手
4.1 源码目录:按控制链路拆,不是按页面拆
一个能称为「高分优秀项目」的仓库,不应该把 ThreeJS 代码全部塞在 .vue 文件里。我按控制链路拆目录,运动学、协议、UI 可以分别测试和替换:
src/
views/
ArmPreviewView.vue
components/
arm/
JointSlider.vue
PosePanel.vue
TrajectoryPanel.vue
scene/
ArmScene.js # ThreeJS 场景、相机、渲染器
ArmBuilder.js # 用几何体或 GLTF 搭机械臂层级
Kinematics.js # 正解、CCD 逆解、限位
stores/
arm.js # jointAngles、speed、playing
services/
controlSocket.js # WebSocket 收发关节角
config/
armModel.js # 关节定义、DH 参数、限位表
这个结构里 .vue 只负责模板和事件绑定,所有 ThreeJS 计算都在 scene 目录下; config/armModel.js 是唯一放机械臂参数的地方,换机械臂型号时不用动组件代码。我一般把 Kinematics.js 设计成不依赖 ThreeJS 渲染器的纯函数:输入 jointAngles 数组,输出末端位姿对象,这样就能直接写单元测试,不用起浏览器。
4.2 开发文档写什么:启动命令、环境变量、关节参数表
开发文档不是 README 复读机,至少要写清楚三件事:怎么跑起来、机械臂参数在哪里改、控制协议长什么样。下面是文档里保留的命令和配置。
# 安装依赖:Vue3 + Vite + Pinia + Three
npm install
# 启动开发服务,默认端口 5173
npm run dev
# 预览模式不连真实机械臂,控制模式走 WebSocket
VITE_CTRL_MODE=mock
VITE_WS_URL=ws://192.168.1.64:9000/arm/state
VITE_CTRL_MODE=mock 时,机械臂预览只读本地滑块数据,不建立控制链路;切换到 ws 模式后再连接真实控制器。前后端可以并行开发,预览效果不依赖实机状态。除了启动命令,文档里还应该有一张完整的关节参数表,和 config/armModel.js 保持一致:
| 参数 | joint1 | joint2 | joint3 | joint4 | joint5 | joint6 |
|---|---|---|---|---|---|---|
| 旋转轴 | y | z | z | y | z | y |
| 角度下限 | -180 | -180 | -90 | -180 | -120 | -180 |
| 角度上限 | 180 | 45 | 90 | 180 | 120 | 180 |
| 减速比 | 101 | 101 | 101 | 51 | 76 | 51 |
这张表如果和代码里的常量不一致,滑块限位和文档对不上,现场调试时会非常被动。我会写一个小脚本,启动时检查文档表格和 armModel.js 里的配置是否同源,少一次手工复制就少一次事故。
4.3 代码解析要点:三个最容易误读的地方
第一处是 group.rotation 的欧拉角顺序。ThreeJS 默认顺序是 XYZ ,当某个轴接近 90 度时会出现万向锁,前端表现为关节突然反转,这时候不要改角度符号,先把 rotation.order 显式设置成与机械臂 DH 表一致的顺序,比如 'ZYX' 。
第二处是 OrbitControls 的 target。 controls.target 改变时会同时影响相机 lookAt,如果机械臂底座不在原点,记得把 target 设到底座中心,否则拖动视角时机械臂会跑出屏幕。
第三处是资源释放。页面切走时,只移除 renderer.domElement 不够,还需要调用 renderer.dispose() ,并遍历场景中的 geometry 和 material 逐个 dispose,否则多开几次预览页,GPU 内存会持续上涨,这是低端机器最容易遇到白屏的来源。
function disposeScene() {
scene.traverse((child) => {
if (child.geometry) child.geometry.dispose()
if (child.material) {
const materials = Array.isArray(child.material) ? child.material : [child.material]
materials.forEach((m) => m.dispose())
}
})
renderer.dispose()
}
scene.traverse 会递归访问所有子对象,比手动记录每个 mesh 更省事。注意材质可能是数组,所以先用 Array.isArray 判断再统一处理。这个 dispose 函数在所有出现机械臂场景切换的地方都能复用。
5. 机械臂控制与真实设备同步:末端轨迹验证技巧
5.1 用末端轨迹线验证预览运动是否合理
预览场景里最直观的验证方法,是给末端画一条轨迹线。每次关节角变化时采样末端位置,并用 THREE.Line 显示出来,轨迹突然折线或穿模,说明挂载点偏移有误,而不是渲染问题。
const trailPoints = []
const trailGeo = new THREE.BufferGeometry()
const trailLine = new THREE.Line(
trailGeo,
new THREE.LineBasicMaterial({ color: 0xffaa33 })
)
scene.add(trailLine)
function sampleTrail() {
const pose = getFlangePose()
trailPoints.push(new THREE.Vector3(pose.x, pose.y, pose.z))
if (trailPoints.length > 500) trailPoints.shift()
trailGeo.setFromPoints(trailPoints)
}
sampleTrail 在滑块变更或轨迹播放的帧回调里调用。 trailPoints.length 上限 500,超过后从头部弹出,避免无限增长导致 BufferGeometry 频繁扩容。线宽在 WebGL 里受平台限制,不要追求粗线,用亮橙色对比暗色背景即可。
5.2 实机同步时的安全守卫
预览和真实机械臂同步,常见做法是 WebSocket 下发关节角,同时订阅实机状态回传。安全上要加两道守卫:角度漂移检查和物理使能开关。
function syncToController() {
const drift = armStore.jointAngles.reduce(
(sum, angle, i) => sum + Math.abs(angle - realAngles[i]),
0
)
if (drift > 2 || !socketReady || !enablePhysical) return
socket.send(JSON.stringify({
type: 'joint_cmd',
angles: armStore.jointAngles,
timestamp: Date.now()
}))
}
drift 是回读的实机角度与预览角度差的绝对值之和,大于 2 度就暂停下发,防止滑块和实机来回拉扯。对 UR10 这类机械臂,如果后端走 ROS,可以订阅控制器发布的关节状态话题,把真实角度直接接进 getFlangePose 的输入,前端只做可视化,不做二次运动学计算。在预览页加一个「物理使能」开关,只有开关打开时才允许 syncToController 执行,这个开关要比任何限位检查都先执行。
更多推荐
所有评论(0)