简介:基于 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 执行,这个开关要比任何限位检查都先执行。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

Logo

聚焦RDK与具身智能,汇集机器人开发教程、开源案例和技术交流

更多推荐