机器人仿真实践(第0卷)——003机器人定义与发展史:从零实现

机器人仿真实践(第0卷)------003机器人定义与发展史:从零实现

本文进入"从零实现"阶段。我们不再只停留在机器人的定义、发展历史和数学物理概念上,而是从一个最简单的虚拟机器人开始,把位置、姿态、速度、加速度、转向和运动控制真正写进程序,让机器人从静止状态一步步"动起来"。通过亲手实现这个最小机器人模型,我们将把前面学过的数学与物理知识第一次连接到代码中,并建立"状态 → 时间推进 → 状态更新 → 运动结果"的基本仿真思维,为后续传感器、控制算法、ROS 2 与专业机器人仿真打下基础。

一、从"看懂机器人"到亲手让它动起来

前面两篇文章,我们已经做了不少准备。

第一篇,我们站在整个机器人领域之外,认识了机器人是什么,也看到了机械臂、AGV、AMR、四足机器人、人形机器人等不同类型。

第二篇,我们又往里面走了一层,开始研究机器人背后的数学和物理:

位置、姿态、速度、加速度、角速度、力、力矩、质量、惯性......

这些知识让我们逐渐明白:

机器人所谓的"运动",并不是屏幕上一个模型从 A 点移动到 B 点这么简单,而是机器人状态随着时间不断发生变化的结果。

但到目前为止,我们仍然只是"看懂了"。

这一篇,我们准备真正跨出下一步:

自己写一个最简单的机器人模拟器,让机器人第一次按照我们写下的数学规则运动起来。

1. 这一次,我们先不用 Gazebo

机器人领域已经有很多成熟的专业仿真工具,比如 Gazebo、Isaac Sim

既然如此,为什么我们还要自己写?

原因其实很简单。

假设现在直接打开 Gazebo,放进去一台机器人,然后给它发送一个速度指令:

text 复制代码
向前速度 = 1 m/s
角速度 = 0.5 rad/s

屏幕里的机器人很快就会开始转弯。

效果很直观。

但如果继续问:

它为什么走出了这条曲线?
0.5 rad/s 到底改变了什么?
软件每一帧究竟计算了什么?
机器人上一时刻的位置,是怎么变成下一时刻位置的?

如果这些问题还没有搞清楚,那么 Gazebo 对我们来说仍然只是一个黑盒:

text 复制代码
输入参数
   ↓
 Gazebo
   ↓
机器人动了

我们知道怎么"使用",却不知道里面最基础的运动过程是怎么发生的。

所以这一篇暂时不急着使用专业仿真器。

我们反过来做一件更简单的事情:

先自己造一个只有最基本运动能力的虚拟机器人。

它不需要 LiDAR。

不需要 IMU。

不需要 SLAM。

不需要 ROS 2。

甚至暂时不考虑质量、摩擦和碰撞。

我们只研究一个最基础的问题:

如果我知道机器人现在在哪里、朝哪里,以及它想以什么速度运动,那么经过一小段时间以后,它应该出现在哪里?

只要这个问题能够解决,第一个机器人模拟器其实就已经开始工作了。


2. 我们先把机器人简化成一个"会动的点"

真实机器人可能非常复杂。

一台 AMR 可能有:

  • 底盘
  • 电机
  • 减速器
  • 编码器
  • IMU
  • LiDAR
  • 摄像头
  • 电池
  • 工控机
  • 通信系统

但这一篇我们暂时全部忽略。

在二维平面里,我们甚至可以先把整个机器人简化成:

text 复制代码
        ↑
      朝向
        │
        ●
     机器人

这个小点只需要几个数据就能够描述:

text 复制代码
x = 机器人在 X 方向的位置

y = 机器人在 Y 方向的位置

θ = 机器人当前朝向

v = 机器人当前线速度

ω = 机器人当前角速度

例如:

text 复制代码
x = 2 m
y = 1 m

θ = 0°

v = 1 m/s
ω = 0 rad/s

它表达的意思就是:

机器人现在位于 (2, 1),朝向 0°,正在以 1 m/s 的速度向前运动,并且当前没有旋转。

我们已经把现实中的一台机器人,压缩成了:

text 复制代码
(x, y, θ, v, ω)

看起来非常简单。

但这几个变量已经足够让我们研究最基础的二维机器人运动。

这也是建模最典型的思路:

先不要试图复制整个现实世界,而是保留当前问题真正需要的东西。


3. 真正让机器人动起来的,不是动画,而是"更新状态"

现在假设机器人初始位置是:

text 复制代码
x = 0
y = 0

我们希望它以:

text 复制代码
v = 1 m/s

向前运动。

最容易想到的做法可能是:

"让屏幕上的机器人向右移动。"

但我们这一篇不会这么做。

因为这样只是制作动画。

我们真正要做的是:

先计算机器人新的位置,再根据新的位置重新绘制机器人。

例如:

text 复制代码
第 0 秒:

x = 0 m

1 秒以后:

text 复制代码
x = 1 m

再过 1 秒:

text 复制代码
x = 2 m

继续:

text 复制代码
x = 3 m

所以屏幕上看到的:

text 复制代码
● → → → → →

背后真正发生的是:

text 复制代码
x = 0
↓
重新计算
↓
x = 1
↓
重新计算
↓
x = 2
↓
重新计算
↓
x = 3

也就是说:

我们不是在"移动一个图形",而是在不断计算机器人的新状态,再把计算结果画出来。

这两种思维看起来非常接近,但对于机器人仿真来说区别非常重要。

前者关注:

画面应该怎么变化?

后者关注:

根据当前状态和运动规律,机器人下一时刻应该处于什么状态?

真正的机器人仿真走的是第二条路线。


4. 把时间切成很多小片,机器人就可以连续运动

当然,真实机器人不可能:

text 复制代码
0 秒:在这里

1 秒:突然跳到 1 米外

它应该是连续运动过去的。

所以我们不会每隔 1 秒才计算一次,而是把时间切得更细。

例如:

text 复制代码
Δt = 0.02 s

也就是每隔:

text 复制代码
20 ms

计算一次。

那么 1 秒钟大约会进行:

text 复制代码
50 次状态更新

于是:

text 复制代码
t = 0.00 s
↓
计算

t = 0.02 s
↓
计算

t = 0.04 s
↓
计算

t = 0.06 s
↓
计算

......

每一次计算,机器人都只向前移动一点点。

大量微小变化连续发生以后,我们在屏幕上看到的就是平滑运动。

整个最小仿真过程,其实可以先概括成:
#mermaid-svg-wU7Sa5c9dcX6aFoc{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-wU7Sa5c9dcX6aFoc .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-wU7Sa5c9dcX6aFoc .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-wU7Sa5c9dcX6aFoc .error-icon{fill:#552222;}#mermaid-svg-wU7Sa5c9dcX6aFoc .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-wU7Sa5c9dcX6aFoc .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-wU7Sa5c9dcX6aFoc .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-wU7Sa5c9dcX6aFoc .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-wU7Sa5c9dcX6aFoc .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-wU7Sa5c9dcX6aFoc .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-wU7Sa5c9dcX6aFoc .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-wU7Sa5c9dcX6aFoc .marker{fill:#333333;stroke:#333333;}#mermaid-svg-wU7Sa5c9dcX6aFoc .marker.cross{stroke:#333333;}#mermaid-svg-wU7Sa5c9dcX6aFoc svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-wU7Sa5c9dcX6aFoc p{margin:0;}#mermaid-svg-wU7Sa5c9dcX6aFoc .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-wU7Sa5c9dcX6aFoc .cluster-label text{fill:#333;}#mermaid-svg-wU7Sa5c9dcX6aFoc .cluster-label span{color:#333;}#mermaid-svg-wU7Sa5c9dcX6aFoc .cluster-label span p{background-color:transparent;}#mermaid-svg-wU7Sa5c9dcX6aFoc .label text,#mermaid-svg-wU7Sa5c9dcX6aFoc span{fill:#333;color:#333;}#mermaid-svg-wU7Sa5c9dcX6aFoc .node rect,#mermaid-svg-wU7Sa5c9dcX6aFoc .node circle,#mermaid-svg-wU7Sa5c9dcX6aFoc .node ellipse,#mermaid-svg-wU7Sa5c9dcX6aFoc .node polygon,#mermaid-svg-wU7Sa5c9dcX6aFoc .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-wU7Sa5c9dcX6aFoc .rough-node .label text,#mermaid-svg-wU7Sa5c9dcX6aFoc .node .label text,#mermaid-svg-wU7Sa5c9dcX6aFoc .image-shape .label,#mermaid-svg-wU7Sa5c9dcX6aFoc .icon-shape .label{text-anchor:middle;}#mermaid-svg-wU7Sa5c9dcX6aFoc .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-wU7Sa5c9dcX6aFoc .rough-node .label,#mermaid-svg-wU7Sa5c9dcX6aFoc .node .label,#mermaid-svg-wU7Sa5c9dcX6aFoc .image-shape .label,#mermaid-svg-wU7Sa5c9dcX6aFoc .icon-shape .label{text-align:center;}#mermaid-svg-wU7Sa5c9dcX6aFoc .node.clickable{cursor:pointer;}#mermaid-svg-wU7Sa5c9dcX6aFoc .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-wU7Sa5c9dcX6aFoc .arrowheadPath{fill:#333333;}#mermaid-svg-wU7Sa5c9dcX6aFoc .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-wU7Sa5c9dcX6aFoc .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-wU7Sa5c9dcX6aFoc .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-wU7Sa5c9dcX6aFoc .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-wU7Sa5c9dcX6aFoc .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-wU7Sa5c9dcX6aFoc .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-wU7Sa5c9dcX6aFoc .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-wU7Sa5c9dcX6aFoc .cluster text{fill:#333;}#mermaid-svg-wU7Sa5c9dcX6aFoc .cluster span{color:#333;}#mermaid-svg-wU7Sa5c9dcX6aFoc div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-wU7Sa5c9dcX6aFoc .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-wU7Sa5c9dcX6aFoc rect.text{fill:none;stroke-width:0;}#mermaid-svg-wU7Sa5c9dcX6aFoc .icon-shape,#mermaid-svg-wU7Sa5c9dcX6aFoc .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-wU7Sa5c9dcX6aFoc .icon-shape p,#mermaid-svg-wU7Sa5c9dcX6aFoc .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-wU7Sa5c9dcX6aFoc .icon-shape .label rect,#mermaid-svg-wU7Sa5c9dcX6aFoc .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-wU7Sa5c9dcX6aFoc .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-wU7Sa5c9dcX6aFoc .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-wU7Sa5c9dcX6aFoc :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 读取当前状态

x, y, θ, v, ω
推进一个时间步

Δt
计算新的状态
重新绘制机器人
进入下一次计算

这就是我们接下来真正要写出来的:

Simulation Loop(仿真循环)。

以后进入 Gazebo、物理引擎甚至更加复杂的机器人仿真时,"时间不断向前推进 → 状态不断重新计算"这个基本思想仍然存在,只不过里面的计算会复杂得多。


5. 这一篇我们最终要做到什么?

这一篇不会追求做出一个功能很多的机器人软件。

我们的目标非常明确:

做出一个最小但真正能够运行的二维机器人运动模拟器。

最终,我们希望看到这样一个画面:

text 复制代码
                  Y
                  ↑
                  │
                  │           ● Robot
                  │        ·
                  │     ·
                  │   ·
                  │ ·
──────────────────●────────────────→ X
                起点

机器人可以:

  • 向前运动
  • 停止
  • 改变速度
  • 向左转
  • 向右转
  • 原地旋转
  • 走出曲线路径
  • 实时显示 (x, y)
  • 实时显示朝向 θ
  • 显示线速度 v
  • 显示角速度 ω
  • 记录运动轨迹
  • 开始、暂停和重置仿真

而真正决定这一切的,不是提前设计好的动画关键帧。

而是几个我们已经认识的变量:

text 复制代码
x
y
θ
v
ω
Δt

下一步,我们就先不写界面,也不急着画机器人。

先把这几个变量真正定义出来。

因为从程序的角度看:

机器人首先不是屏幕上的那个小车,而是一组能够描述它当前状态的数据。

只要我们能够正确维护这组状态,并且让它随着时间按照正确的数学关系不断变化,一个最基础的机器人模拟器就已经有了"生命"。

二、先造一个最简单的机器人:我们究竟要实现什么?

上一节我们已经确定了一件事:

这一篇暂时不用 Gazebo,也不调用现成的机器人运动框架,而是自己从零写一个最小的二维机器人模拟器。

不过在真正开始写代码之前,还有一件事情必须先确定:

我们究竟准备模拟一台什么样的机器人?

机器人种类太多了。

机械臂需要考虑关节和连杆;无人机需要考虑三维空间、升力和姿态;四足机器人还要处理步态、接触和平衡。

如果第一步就把这些东西全部塞进来,我们很快又会掉进一个巨大的黑盒。

所以这一次,我们选择机器人领域最容易理解的一类:

二维平面移动机器人(2D Mobile Robot)。

你可以把它想象成一台最简单的 AMR:它生活在一块平整的地面上,可以向前、转弯和原地旋转。

我们的目标不是把真实 AMR 的所有零件复制出来,而是先回答一个最核心的问题:

如果我给机器人一个线速度和角速度,它接下来会怎么运动?

1. 先规定这个虚拟机器人生活在什么世界里

真实机器人面对的世界非常复杂。

地面可能有坡度;

轮子可能打滑;

电机存在响应延迟;

机器人有质量和惯性;

周围还有墙壁、障碍物和其他机器人。

但这些暂时都不是我们现在要研究的问题。

所以第一版模拟器先做一些非常大胆的假设:

text 复制代码
机器人只在二维平面运动

地面完全平整

轮子不会打滑

机器人没有碰撞

暂时不考虑质量和惯性

暂时不模拟电机

暂时没有任何传感器

控制命令能够立即生效

也就是说,我们现在研究的是一个非常理想化的机器人。

这并不是因为真实机器人真的这么简单,而是因为我们当前只想研究:

机器人在二维平面中的基本运动关系。

这就是建模时经常使用的思路:

先把无关因素拿掉,只留下当前问题真正需要的部分。

等这个最简单的模型跑通以后,我们完全可以继续往里面增加:

text 复制代码
Encoder
IMU
LiDAR
质量
惯性
摩擦
轮胎打滑
碰撞
障碍物
控制器
......

于是一个简单模型,就可以逐渐向真实机器人靠近。


2. 这个机器人最少需要保存哪些数据?

现在我们把机器人放进二维世界。

假设它现在位于:

text 复制代码
x = 2 m
y = 1 m

那么 (x, y) 已经告诉我们:

机器人在哪里。

但上一篇我们已经知道,只知道位置还不够。

机器人还需要一个:

text 复制代码
θ

表示当前朝向。

例如:

text 复制代码
θ = 0°

可以规定为朝向 X 轴正方向。

text 复制代码
θ = 90°

表示机器人已经向左旋转了 90°。

于是:

text 复制代码
x
y
θ

就描述了机器人的位姿(Pose)

但是机器人还要运动。

所以我们继续增加:

text 复制代码
v

表示:

线速度(Linear Velocity)

以及:

text 复制代码
ω

表示:

角速度(Angular Velocity)

最终,我们的第一台虚拟机器人只需要保存五个核心变量:

变量 含义 例子
x X 方向位置 2.0 m
y Y 方向位置 1.0 m
θ 当前朝向 30°
v 线速度 1.0 m/s
ω 角速度 0.5 rad/s

可以把它们分成两组来看。

第一组:

text 复制代码
x
y
θ

回答:

机器人现在是什么状态?

第二组:

text 复制代码
v
ω

回答:

机器人现在准备怎么运动?

于是我们已经可以把一台复杂的真实机器人,暂时压缩成:

text 复制代码
Robot = (x, y, θ, v, ω)

这就是我们第一个真正要写进程序里的机器人状态(Robot State)


3. 我们到底准备让它实现哪些动作?

有了这五个变量以后,这个机器人其实已经可以表现出很多不同的运动。

先看最简单的情况。

如果:

text 复制代码
v = 1.0 m/s
ω = 0

机器人有向前速度,但没有旋转。

那么它应该:

直线前进。

如果:

text 复制代码
v = 1.0 m/s
ω = 0.5 rad/s

机器人一边向前运动,一边改变朝向。

那么它就会:

走出一条弯曲的轨迹。

如果:

text 复制代码
v = 0
ω = 0.5 rad/s

机器人没有向前移动,但仍然存在角速度。

对于我们现在采用的理想二维运动模型,可以让它:

原地旋转。

如果:

text 复制代码
v = 0
ω = 0

那么机器人就:

停止。

所以,仅仅通过两个变量:

text 复制代码
v
ω

我们就已经可以控制最基础的几种运动:

v ω 机器人运动
> 0 = 0 向前直行
< 0 = 0 向后直行
> 0 > 0 一边前进一边转弯
> 0 < 0 向另一个方向转弯
= 0 ≠ 0 原地旋转
= 0 = 0 停止

这张表非常重要。

因为我们实际上已经把一句很模糊的人类命令:

"机器人往前走,然后向左拐。"

转换成了计算机可以处理的数据:

text 复制代码
先:

v = 1.0
ω = 0

然后:

v = 1.0
ω = 0.5

机器人控制开始从"语言"变成了数字


4. 最终画面只是状态的"可视化"

现在我们再考虑界面。

我们当然希望最后看到一个机器人在屏幕里运动。

例如:

text 复制代码
                Y
                ↑
                │
                │          ↑
                │         ┌─┐
                │      ·  │ │ Robot
                │    ·    └─┘
                │  ·
                │·
────────────────●────────────────→ X
              起点

机器人后面可以留下运动轨迹。

旁边再实时显示:

text 复制代码
Position

x = 3.24 m
y = 1.68 m


Orientation

θ = 42.5°


Velocity

v = 1.00 m/s
ω = 0.35 rad/s


Simulation

t = 6.42 s

再提供几个最基本的控制:

text 复制代码
开始

暂停

重置

以及:

text 复制代码
线速度 v

角速度 ω

的调节。

但这里一定要把一个思想分清楚:

屏幕上的机器人不是我们的"机器人模型",它只是机器人状态的可视化结果。

真正的机器人其实存在于程序的数据里:

text 复制代码
x
y
θ
v
ω

程序先计算这些数据。

然后界面读取:

text 复制代码
x = 3.24
y = 1.68
θ = 42.5°

再决定:

机器人图形应该画在哪里、旋转多少度。

所以整个程序应该保持这样的关系:
#mermaid-svg-cnDeo2sOP5Tdrg10{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-cnDeo2sOP5Tdrg10 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-cnDeo2sOP5Tdrg10 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-cnDeo2sOP5Tdrg10 .error-icon{fill:#552222;}#mermaid-svg-cnDeo2sOP5Tdrg10 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-cnDeo2sOP5Tdrg10 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-cnDeo2sOP5Tdrg10 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-cnDeo2sOP5Tdrg10 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-cnDeo2sOP5Tdrg10 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-cnDeo2sOP5Tdrg10 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-cnDeo2sOP5Tdrg10 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-cnDeo2sOP5Tdrg10 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-cnDeo2sOP5Tdrg10 .marker.cross{stroke:#333333;}#mermaid-svg-cnDeo2sOP5Tdrg10 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-cnDeo2sOP5Tdrg10 p{margin:0;}#mermaid-svg-cnDeo2sOP5Tdrg10 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-cnDeo2sOP5Tdrg10 .cluster-label text{fill:#333;}#mermaid-svg-cnDeo2sOP5Tdrg10 .cluster-label span{color:#333;}#mermaid-svg-cnDeo2sOP5Tdrg10 .cluster-label span p{background-color:transparent;}#mermaid-svg-cnDeo2sOP5Tdrg10 .label text,#mermaid-svg-cnDeo2sOP5Tdrg10 span{fill:#333;color:#333;}#mermaid-svg-cnDeo2sOP5Tdrg10 .node rect,#mermaid-svg-cnDeo2sOP5Tdrg10 .node circle,#mermaid-svg-cnDeo2sOP5Tdrg10 .node ellipse,#mermaid-svg-cnDeo2sOP5Tdrg10 .node polygon,#mermaid-svg-cnDeo2sOP5Tdrg10 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-cnDeo2sOP5Tdrg10 .rough-node .label text,#mermaid-svg-cnDeo2sOP5Tdrg10 .node .label text,#mermaid-svg-cnDeo2sOP5Tdrg10 .image-shape .label,#mermaid-svg-cnDeo2sOP5Tdrg10 .icon-shape .label{text-anchor:middle;}#mermaid-svg-cnDeo2sOP5Tdrg10 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-cnDeo2sOP5Tdrg10 .rough-node .label,#mermaid-svg-cnDeo2sOP5Tdrg10 .node .label,#mermaid-svg-cnDeo2sOP5Tdrg10 .image-shape .label,#mermaid-svg-cnDeo2sOP5Tdrg10 .icon-shape .label{text-align:center;}#mermaid-svg-cnDeo2sOP5Tdrg10 .node.clickable{cursor:pointer;}#mermaid-svg-cnDeo2sOP5Tdrg10 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-cnDeo2sOP5Tdrg10 .arrowheadPath{fill:#333333;}#mermaid-svg-cnDeo2sOP5Tdrg10 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-cnDeo2sOP5Tdrg10 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-cnDeo2sOP5Tdrg10 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-cnDeo2sOP5Tdrg10 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-cnDeo2sOP5Tdrg10 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-cnDeo2sOP5Tdrg10 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-cnDeo2sOP5Tdrg10 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-cnDeo2sOP5Tdrg10 .cluster text{fill:#333;}#mermaid-svg-cnDeo2sOP5Tdrg10 .cluster span{color:#333;}#mermaid-svg-cnDeo2sOP5Tdrg10 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-cnDeo2sOP5Tdrg10 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-cnDeo2sOP5Tdrg10 rect.text{fill:none;stroke-width:0;}#mermaid-svg-cnDeo2sOP5Tdrg10 .icon-shape,#mermaid-svg-cnDeo2sOP5Tdrg10 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-cnDeo2sOP5Tdrg10 .icon-shape p,#mermaid-svg-cnDeo2sOP5Tdrg10 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-cnDeo2sOP5Tdrg10 .icon-shape .label rect,#mermaid-svg-cnDeo2sOP5Tdrg10 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-cnDeo2sOP5Tdrg10 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-cnDeo2sOP5Tdrg10 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-cnDeo2sOP5Tdrg10 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 机器人状态

x · y · θ · v · ω
运动模型
计算新状态
界面读取状态
绘制机器人和轨迹

这也是为什么我们这一篇不会一开始就花大量时间设计漂亮界面。

先把机器人算对,再把它画出来。


5. 我们的第一版模拟器,故意"不做什么"

最后还要给这个项目划一条边界。

第一版我们不会实现:

text 复制代码
SLAM
LiDAR
IMU
Encoder
A*
Nav2
PID
碰撞检测
复杂物理
电机模型
ROS 2
Gazebo

因为这些东西以后都会单独研究。

如果现在全部加入,反而看不清最基础的运动模型。

所以第一版只做:

text 复制代码
二维坐标系
+
机器人状态
+
线速度
+
角速度
+
时间推进
+
运动轨迹
+
简单控制

可以把整个 Demo 的目标压缩成一张非常简单的图:
#mermaid-svg-kPnvgZPsYd9TcR1T{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-kPnvgZPsYd9TcR1T .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-kPnvgZPsYd9TcR1T .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-kPnvgZPsYd9TcR1T .error-icon{fill:#552222;}#mermaid-svg-kPnvgZPsYd9TcR1T .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-kPnvgZPsYd9TcR1T .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-kPnvgZPsYd9TcR1T .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-kPnvgZPsYd9TcR1T .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-kPnvgZPsYd9TcR1T .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-kPnvgZPsYd9TcR1T .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-kPnvgZPsYd9TcR1T .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-kPnvgZPsYd9TcR1T .marker{fill:#333333;stroke:#333333;}#mermaid-svg-kPnvgZPsYd9TcR1T .marker.cross{stroke:#333333;}#mermaid-svg-kPnvgZPsYd9TcR1T svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-kPnvgZPsYd9TcR1T p{margin:0;}#mermaid-svg-kPnvgZPsYd9TcR1T .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-kPnvgZPsYd9TcR1T .cluster-label text{fill:#333;}#mermaid-svg-kPnvgZPsYd9TcR1T .cluster-label span{color:#333;}#mermaid-svg-kPnvgZPsYd9TcR1T .cluster-label span p{background-color:transparent;}#mermaid-svg-kPnvgZPsYd9TcR1T .label text,#mermaid-svg-kPnvgZPsYd9TcR1T span{fill:#333;color:#333;}#mermaid-svg-kPnvgZPsYd9TcR1T .node rect,#mermaid-svg-kPnvgZPsYd9TcR1T .node circle,#mermaid-svg-kPnvgZPsYd9TcR1T .node ellipse,#mermaid-svg-kPnvgZPsYd9TcR1T .node polygon,#mermaid-svg-kPnvgZPsYd9TcR1T .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-kPnvgZPsYd9TcR1T .rough-node .label text,#mermaid-svg-kPnvgZPsYd9TcR1T .node .label text,#mermaid-svg-kPnvgZPsYd9TcR1T .image-shape .label,#mermaid-svg-kPnvgZPsYd9TcR1T .icon-shape .label{text-anchor:middle;}#mermaid-svg-kPnvgZPsYd9TcR1T .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-kPnvgZPsYd9TcR1T .rough-node .label,#mermaid-svg-kPnvgZPsYd9TcR1T .node .label,#mermaid-svg-kPnvgZPsYd9TcR1T .image-shape .label,#mermaid-svg-kPnvgZPsYd9TcR1T .icon-shape .label{text-align:center;}#mermaid-svg-kPnvgZPsYd9TcR1T .node.clickable{cursor:pointer;}#mermaid-svg-kPnvgZPsYd9TcR1T .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-kPnvgZPsYd9TcR1T .arrowheadPath{fill:#333333;}#mermaid-svg-kPnvgZPsYd9TcR1T .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-kPnvgZPsYd9TcR1T .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-kPnvgZPsYd9TcR1T .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kPnvgZPsYd9TcR1T .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-kPnvgZPsYd9TcR1T .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kPnvgZPsYd9TcR1T .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-kPnvgZPsYd9TcR1T .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-kPnvgZPsYd9TcR1T .cluster text{fill:#333;}#mermaid-svg-kPnvgZPsYd9TcR1T .cluster span{color:#333;}#mermaid-svg-kPnvgZPsYd9TcR1T div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-kPnvgZPsYd9TcR1T .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-kPnvgZPsYd9TcR1T rect.text{fill:none;stroke-width:0;}#mermaid-svg-kPnvgZPsYd9TcR1T .icon-shape,#mermaid-svg-kPnvgZPsYd9TcR1T .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kPnvgZPsYd9TcR1T .icon-shape p,#mermaid-svg-kPnvgZPsYd9TcR1T .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-kPnvgZPsYd9TcR1T .icon-shape .label rect,#mermaid-svg-kPnvgZPsYd9TcR1T .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kPnvgZPsYd9TcR1T .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-kPnvgZPsYd9TcR1T .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-kPnvgZPsYd9TcR1T :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 给定 v 和 ω
读取当前

x · y · θ
经过 Δt
计算新的

x · y · θ
重新绘制

只要这个循环能够持续运行,我们的第一个机器人模拟器就已经成立了。

而且到了这里,我们终于把问题缩小到了一个非常具体的程度。

现在已经不需要继续讨论:

"机器人是什么?"

也不需要讨论:

"仿真到底是什么?"

接下来只剩下一个真正需要解决的问题:

已知机器人现在的 (x, y, θ),再给它线速度 v、角速度 ω 和一小段时间 Δt,我们到底应该用什么公式计算下一时刻的 (x, y, θ)

下一节,我们就把这个运动方程真正写出来。

三、给机器人建立状态:位置、姿态、速度与时间

上一节,我们已经把第一台虚拟机器人简化成了几个最基本的变量:

text 复制代码
x
y
θ
v
ω

但现在它们还只是几个孤零零的数字。

真正开始写模拟器时,我们需要进一步明确:

这些数据分别代表什么?哪些是在描述机器人"现在是什么样",哪些是在描述机器人"接下来准备怎么动"?

这一步非常重要。

因为后面无论是我们自己写的小型模拟器,还是进入 ROS 2、Gazebo、SLAM、Nav2,机器人程序始终都在做一件事情:

不断读取当前状态,再计算下一时刻的状态。

所以这一节,我们先不给机器人写运动公式,而是先给它建立一份完整的"状态档案"。

1. xy:机器人现在在哪里?

先建立一个二维坐标系:

text 复制代码
                 Y
                 ↑
                 │
                 │
                 │          ● Robot
                 │        (3, 2)
                 │
                 │
─────────────────●──────────────────→ X
               (0,0)

我们规定左下角的某个位置是原点:

text 复制代码
(0, 0)

现在机器人位于:

text 复制代码
x = 3 m
y = 2 m

这意味着:

相对于我们规定的世界坐标系原点,机器人沿 X 方向移动了 3 m,沿 Y 方向移动了 2 m。

所以:

text 复制代码
(x, y)

描述的是机器人的位置(Position)

在程序中,我们可以先直接写:

javascript 复制代码
let x = 3.0;
let y = 2.0;

这里先规定:

text 复制代码
1 个位置单位 = 1 m

当然,真正绘制到屏幕上时,我们不可能真的画一个"3 m"的机器人。

所以后面还会建立:

text 复制代码
现实单位 m
      ↓
显示比例 Scale
      ↓
屏幕像素 px

例如:

text 复制代码
1 m = 50 px

那么:

text 复制代码
x = 3 m

在屏幕上就可以显示为:

text 复制代码
150 px

但要注意:

机器人的真实状态应该保存为米,而不是保存为屏幕像素。

因为 x = 3 m 是机器人模型的数据;

150 px 只是界面为了把它画出来而进行的显示转换。

这两个概念从一开始就应该分开。


2. θ:机器人在哪里还不够,还要知道它朝哪里

现在机器人已经位于:

text 复制代码
x = 3 m
y = 2 m

但如果只知道这些,我们仍然无法确定它接下来向哪个方向运动。

因为同一个位置可以存在完全不同的朝向:

text 复制代码
        ↑
        ●
      朝北


←  ●        ●  →
朝西          朝东


        ●
        ↓
      朝南

所以还需要一个变量:

text 复制代码
θ

表示机器人的朝向角(Heading / Orientation)

例如我们规定:

text 复制代码
θ = 0°

机器人朝向 X 轴正方向:

text 复制代码

如果:

text 复制代码
θ = 90°

机器人朝向 Y 轴正方向:

text 复制代码

于是:

text 复制代码
x
y
θ

三个变量组合起来,就能够描述机器人在二维空间中的基本位姿(Pose)

text 复制代码
Pose = (x, y, θ)

在代码里可以继续增加:

javascript 复制代码
let theta = 0;

不过这里有一个非常重要的细节。

我们人平时喜欢使用:

text 复制代码
0°
90°
180°
360°

但 JavaScript 中的:

javascript 复制代码
Math.sin()
Math.cos()

使用的是:

弧度(Radian)

而不是角度(Degree)。

所以后面真正计算机器人运动时,我们会使用:

text 复制代码
0°   = 0 rad

90°  = π/2 rad

180° = π rad

360° = 2π rad

因此程序内部最好统一使用:

text 复制代码
rad

而界面展示时,再转换成人更容易阅读的:

text 复制代码
°

例如:

javascript 复制代码
const degree = theta * 180 / Math.PI;

这又体现了一个很重要的工程习惯:

内部计算使用统一、适合数学运算的单位;界面显示再转换成适合人阅读的单位。


3. vω:机器人准备怎么运动?

现在我们已经知道:

text 复制代码
x
y
θ

也就是:

机器人现在在哪里、朝哪里。

接下来就需要告诉它:

你准备怎么动?

最基础的两个控制量就是:

text 复制代码
v

和:

text 复制代码
ω

其中:

text 复制代码
v = Linear Velocity

表示线速度

例如:

text 复制代码
v = 1.0 m/s

可以理解为:

机器人沿着自己当前朝向,以 1 m/s 的速度前进。

如果:

text 复制代码
v = 0

机器人不向前移动。

如果允许倒车,那么:

text 复制代码
v < 0

就可以表示沿当前朝向的反方向运动。

另一个变量:

text 复制代码
ω = Angular Velocity

表示角速度

例如:

text 复制代码
ω = 0.5 rad/s

表示:

机器人的朝向正在以每秒 0.5 rad 的速度发生变化。

如果:

text 复制代码
ω = 0

朝向不变。

如果我们约定:

text 复制代码
ω > 0

表示逆时针旋转,

那么:

text 复制代码
ω < 0

就表示顺时针旋转。

于是:

text 复制代码
v

控制机器人:

走得多快。

而:

text 复制代码
ω

控制机器人:

转得多快。

两者组合以后,就能产生不同运动:

v ω 运动状态
1.0 0 向前直行
-1.0 0 向后直行
1.0 0.5 前进并转弯
1.0 -0.5 向另一侧转弯
0 0.5 原地旋转
0 0 停止

现在,我们的机器人状态已经开始完整起来:

javascript 复制代码
let x = 0.0;
let y = 0.0;
let theta = 0.0;

let v = 1.0;
let omega = 0.0;

但它仍然不会动。

因为还缺最后一个非常关键的东西:

时间。


4. tΔt:没有时间,所有状态都不会变化

假设现在:

text 复制代码
x = 0 m
v = 1 m/s

这只能告诉我们:

机器人现在在 0 m,并且正在以 1 m/s 的速度运动。

但如果时间不向前推进:

text 复制代码
t = 0

永远停在那里,

那么:

text 复制代码
x

也永远不会发生变化。

所以仿真必须引入:

text 复制代码
t

表示:

当前仿真时间(Simulation Time)

以及:

text 复制代码
Δt

表示:

每一次计算向前推进多少时间(Time Step)。

例如:

text 复制代码
Δt = 0.02 s

意味着:

每一次状态更新,都假设现实世界向前推进了 0.02 秒。

于是:

text 复制代码
第 1 次计算:

t = 0.00 s


第 2 次计算:

t = 0.02 s


第 3 次计算:

t = 0.04 s


第 4 次计算:

t = 0.06 s

不断继续下去。

代码里可以写成:

javascript 复制代码
let time = 0.0;
const dt = 0.02;

每完成一次仿真:

javascript 复制代码
time += dt;

于是时间就开始向前推进。

但这里一定要区分:

仿真时间(Simulation Time)

现实时间(Wall Time)

并不一定永远完全相同。

比如我们完全可以让:

text 复制代码
现实世界过去 1 秒

而仿真内部已经计算了:

text 复制代码
10 秒

这就是加速仿真。

反过来,也可以让一个复杂物理过程:

text 复制代码
仿真世界只过去 1 秒

计算机却花了:

text 复制代码
5 秒

才算完。

所以对于模拟器来说,真正决定机器人运动计算的是:

仿真时间推进了多少,而不是电脑屏幕前真实过去了多少秒。

这个思想以后进入 Gazebo 时还会再次遇到。


5. 到这里,我们的第一个机器人已经有了一份完整"状态表"

现在把所有变量放在一起:

text 复制代码
机器人状态
────────────────────────

位置:

x = 0.0 m
y = 0.0 m


姿态:

θ = 0.0 rad


运动:

v = 1.0 m/s
ω = 0.0 rad/s


时间:

t = 0.0 s
Δt = 0.02 s

在 JavaScript 里,我们完全可以先把它们组织成一个对象:

javascript 复制代码
const robot = {
    x: 0.0,
    y: 0.0,
    theta: 0.0,

    v: 1.0,
    omega: 0.0
};

let simulationTime = 0.0;
const dt = 0.02;

这几行代码看起来非常普通。

但实际上,我们已经完成了第一件真正属于机器人仿真的事情:

用一组数字,在计算机中描述一台虚拟机器人当前的状态。

此时屏幕上甚至可以什么都没有。

没有小车图片。

没有 Canvas。

没有动画。

没有按钮。

但从模型角度来说:

机器人已经存在了。

因为我们已经知道:

text 复制代码
它在哪里

它朝哪里

它移动多快

它旋转多快

当前仿真时间是多少

接下来真正需要解决的问题就只剩下:

经过 Δt = 0.02 s 以后,这些数字应该变成什么?

也就是说,我们需要找到:

text 复制代码
当前状态

(x, y, θ)

        +

运动状态

(v, ω)

        +

时间

Δt

        ↓

下一时刻状态

(x', y', θ')

一旦这个关系建立起来,程序就不再只是"保存一台机器人"。

它将第一次拥有:

计算机器人运动的能力。

下一节,我们就正式写出这个最小二维机器人的运动方程,让这组静止的数据真正开始变化。

四、让时间流动起来:一步步计算机器人的运动

现在机器人已经有了:

text 复制代码
位置:x、y
朝向:θ
线速度:v
角速度:ω
时间步:Δt

接下来只需要解决一个问题:

经过一个 Δt,机器人新的位置和朝向是多少?

1. 先看最简单的直线运动

假设机器人当前:

text 复制代码
x = 0
y = 0
θ = 0°
v = 1 m/s
Δt = 0.02 s

因为 θ = 0°,机器人正朝 X 轴方向前进。

经过 0.02 s

text 复制代码
移动距离 = v × Δt
         = 1 × 0.02
         = 0.02 m

所以:

text 复制代码
x:0 → 0.02 m

再计算一次:

text 复制代码
x:0.02 → 0.04 m

不断重复以后,机器人就会持续向前运动。

2. 如果机器人有朝向,就把速度拆到 X、Y 两个方向

如果机器人现在朝着 θ 方向运动,就不能简单地只修改 x

这时候使用:

text 复制代码
x_new = x + v × cos(θ) × Δt

y_new = y + v × sin(θ) × Δt

如果机器人同时还在旋转:

text 复制代码
θ_new = θ + ω × Δt

这三个公式,就是我们这个最小模拟器最核心的运动模型。

例如:

text 复制代码
v = 1 m/s
ω = 0

机器人会一直直行。

而:

text 复制代码
v = 1 m/s
ω = 0.5 rad/s

机器人一边向前运动,一边不断改变 θ,最终就会走出一条曲线。

3. 把公式真正写进 JavaScript

代码其实非常短:

javascript 复制代码
function updateRobot(robot, dt) {
    robot.x += robot.v * Math.cos(robot.theta) * dt;
    robot.y += robot.v * Math.sin(robot.theta) * dt;
    robot.theta += robot.omega * dt;
}

然后不断执行:

javascript 复制代码
updateRobot(robot, dt);

整个模拟器最核心的逻辑就已经跑起来了:
#mermaid-svg-rgzH8ddTskFrh6QX{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-rgzH8ddTskFrh6QX .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-rgzH8ddTskFrh6QX .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-rgzH8ddTskFrh6QX .error-icon{fill:#552222;}#mermaid-svg-rgzH8ddTskFrh6QX .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-rgzH8ddTskFrh6QX .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-rgzH8ddTskFrh6QX .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-rgzH8ddTskFrh6QX .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-rgzH8ddTskFrh6QX .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-rgzH8ddTskFrh6QX .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-rgzH8ddTskFrh6QX .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-rgzH8ddTskFrh6QX .marker{fill:#333333;stroke:#333333;}#mermaid-svg-rgzH8ddTskFrh6QX .marker.cross{stroke:#333333;}#mermaid-svg-rgzH8ddTskFrh6QX svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-rgzH8ddTskFrh6QX p{margin:0;}#mermaid-svg-rgzH8ddTskFrh6QX .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-rgzH8ddTskFrh6QX .cluster-label text{fill:#333;}#mermaid-svg-rgzH8ddTskFrh6QX .cluster-label span{color:#333;}#mermaid-svg-rgzH8ddTskFrh6QX .cluster-label span p{background-color:transparent;}#mermaid-svg-rgzH8ddTskFrh6QX .label text,#mermaid-svg-rgzH8ddTskFrh6QX span{fill:#333;color:#333;}#mermaid-svg-rgzH8ddTskFrh6QX .node rect,#mermaid-svg-rgzH8ddTskFrh6QX .node circle,#mermaid-svg-rgzH8ddTskFrh6QX .node ellipse,#mermaid-svg-rgzH8ddTskFrh6QX .node polygon,#mermaid-svg-rgzH8ddTskFrh6QX .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-rgzH8ddTskFrh6QX .rough-node .label text,#mermaid-svg-rgzH8ddTskFrh6QX .node .label text,#mermaid-svg-rgzH8ddTskFrh6QX .image-shape .label,#mermaid-svg-rgzH8ddTskFrh6QX .icon-shape .label{text-anchor:middle;}#mermaid-svg-rgzH8ddTskFrh6QX .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-rgzH8ddTskFrh6QX .rough-node .label,#mermaid-svg-rgzH8ddTskFrh6QX .node .label,#mermaid-svg-rgzH8ddTskFrh6QX .image-shape .label,#mermaid-svg-rgzH8ddTskFrh6QX .icon-shape .label{text-align:center;}#mermaid-svg-rgzH8ddTskFrh6QX .node.clickable{cursor:pointer;}#mermaid-svg-rgzH8ddTskFrh6QX .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-rgzH8ddTskFrh6QX .arrowheadPath{fill:#333333;}#mermaid-svg-rgzH8ddTskFrh6QX .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-rgzH8ddTskFrh6QX .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-rgzH8ddTskFrh6QX .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rgzH8ddTskFrh6QX .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-rgzH8ddTskFrh6QX .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rgzH8ddTskFrh6QX .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-rgzH8ddTskFrh6QX .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-rgzH8ddTskFrh6QX .cluster text{fill:#333;}#mermaid-svg-rgzH8ddTskFrh6QX .cluster span{color:#333;}#mermaid-svg-rgzH8ddTskFrh6QX div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-rgzH8ddTskFrh6QX .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-rgzH8ddTskFrh6QX rect.text{fill:none;stroke-width:0;}#mermaid-svg-rgzH8ddTskFrh6QX .icon-shape,#mermaid-svg-rgzH8ddTskFrh6QX .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rgzH8ddTskFrh6QX .icon-shape p,#mermaid-svg-rgzH8ddTskFrh6QX .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-rgzH8ddTskFrh6QX .icon-shape .label rect,#mermaid-svg-rgzH8ddTskFrh6QX .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rgzH8ddTskFrh6QX .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-rgzH8ddTskFrh6QX .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-rgzH8ddTskFrh6QX :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 读取 x、y、θ
读取 v、ω
推进 Δt
计算新的 x、y、θ

这里最值得理解的其实不是 JavaScript,而是:

机器人并不是被动画"拖着走",而是程序每经过一个很小的时间步,都重新计算一次它应该在哪里。

例如 Δt = 0.02 s,1 秒钟就会更新大约 50 次。

接下来我们只需要把每一次计算得到的 (x, y, θ) 画到屏幕上,就能真正看到自己写的机器人动起来了。

五、加入控制:让机器人前进、转弯并到达指定位置

上一节,我们已经能够根据 vω 不断计算机器人的新位置。

现在只差一步:

不要让机器人一直按照固定速度运动,而是让我们能够控制它。

1. 前进、停止和转弯,其实就是修改 vω

我们可以先定义几个最简单的控制:

javascript 复制代码
function moveForward() {
    robot.v = 1.0;
    robot.omega = 0.0;
}

function turnLeft() {
    robot.v = 1.0;
    robot.omega = 0.5;
}

function turnRight() {
    robot.v = 1.0;
    robot.omega = -0.5;
}

function stopRobot() {
    robot.v = 0.0;
    robot.omega = 0.0;
}

于是:

text 复制代码
前进 → v > 0,ω = 0

左转 → v > 0,ω > 0

右转 → v > 0,ω < 0

停止 → v = 0,ω = 0

到这里,我们已经可以通过按钮或者键盘改变机器人运动状态。

2. 再进一步:告诉机器人"去这里"

手动控制只是第一步。

真正有意思的是,我们不给机器人规定每一步怎么走,而是只给它一个目标:

text 复制代码
目标点:

targetX = 5 m
targetY = 3 m

机器人首先可以计算:

javascript 复制代码
const dx = targetX - robot.x;
const dy = targetY - robot.y;

const targetAngle = Math.atan2(dy, dx);
const distance = Math.sqrt(dx * dx + dy * dy);

这里得到两个非常重要的信息:

text 复制代码
distance
→ 目标离我还有多远?

targetAngle
→ 目标在我的哪个方向?

接下来就可以建立一个最简单的控制逻辑:

text 复制代码
目标在左边
→ 向左转

目标在右边
→ 向右转

方向基本正确
→ 向前走

距离足够小
→ 停止

于是机器人第一次不再只是执行:

"向前走。"

而是开始执行:

"去 (5, 3)。"

3. 从"控制动作"到"控制目标"

这其实是一个非常重要的变化。

最开始,我们控制的是:

text 复制代码
v
ω

也就是:

机器人下一步怎么动?

现在我们开始给它:

text 复制代码
targetX
targetY

也就是:

机器人最终要去哪里?

程序则负责根据当前位置不断计算:
#mermaid-svg-k0XrtNUC1jBMGAUW{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-k0XrtNUC1jBMGAUW .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-k0XrtNUC1jBMGAUW .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-k0XrtNUC1jBMGAUW .error-icon{fill:#552222;}#mermaid-svg-k0XrtNUC1jBMGAUW .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-k0XrtNUC1jBMGAUW .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-k0XrtNUC1jBMGAUW .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-k0XrtNUC1jBMGAUW .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-k0XrtNUC1jBMGAUW .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-k0XrtNUC1jBMGAUW .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-k0XrtNUC1jBMGAUW .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-k0XrtNUC1jBMGAUW .marker{fill:#333333;stroke:#333333;}#mermaid-svg-k0XrtNUC1jBMGAUW .marker.cross{stroke:#333333;}#mermaid-svg-k0XrtNUC1jBMGAUW svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-k0XrtNUC1jBMGAUW p{margin:0;}#mermaid-svg-k0XrtNUC1jBMGAUW .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-k0XrtNUC1jBMGAUW .cluster-label text{fill:#333;}#mermaid-svg-k0XrtNUC1jBMGAUW .cluster-label span{color:#333;}#mermaid-svg-k0XrtNUC1jBMGAUW .cluster-label span p{background-color:transparent;}#mermaid-svg-k0XrtNUC1jBMGAUW .label text,#mermaid-svg-k0XrtNUC1jBMGAUW span{fill:#333;color:#333;}#mermaid-svg-k0XrtNUC1jBMGAUW .node rect,#mermaid-svg-k0XrtNUC1jBMGAUW .node circle,#mermaid-svg-k0XrtNUC1jBMGAUW .node ellipse,#mermaid-svg-k0XrtNUC1jBMGAUW .node polygon,#mermaid-svg-k0XrtNUC1jBMGAUW .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-k0XrtNUC1jBMGAUW .rough-node .label text,#mermaid-svg-k0XrtNUC1jBMGAUW .node .label text,#mermaid-svg-k0XrtNUC1jBMGAUW .image-shape .label,#mermaid-svg-k0XrtNUC1jBMGAUW .icon-shape .label{text-anchor:middle;}#mermaid-svg-k0XrtNUC1jBMGAUW .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-k0XrtNUC1jBMGAUW .rough-node .label,#mermaid-svg-k0XrtNUC1jBMGAUW .node .label,#mermaid-svg-k0XrtNUC1jBMGAUW .image-shape .label,#mermaid-svg-k0XrtNUC1jBMGAUW .icon-shape .label{text-align:center;}#mermaid-svg-k0XrtNUC1jBMGAUW .node.clickable{cursor:pointer;}#mermaid-svg-k0XrtNUC1jBMGAUW .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-k0XrtNUC1jBMGAUW .arrowheadPath{fill:#333333;}#mermaid-svg-k0XrtNUC1jBMGAUW .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-k0XrtNUC1jBMGAUW .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-k0XrtNUC1jBMGAUW .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-k0XrtNUC1jBMGAUW .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-k0XrtNUC1jBMGAUW .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-k0XrtNUC1jBMGAUW .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-k0XrtNUC1jBMGAUW .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-k0XrtNUC1jBMGAUW .cluster text{fill:#333;}#mermaid-svg-k0XrtNUC1jBMGAUW .cluster span{color:#333;}#mermaid-svg-k0XrtNUC1jBMGAUW div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-k0XrtNUC1jBMGAUW .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-k0XrtNUC1jBMGAUW rect.text{fill:none;stroke-width:0;}#mermaid-svg-k0XrtNUC1jBMGAUW .icon-shape,#mermaid-svg-k0XrtNUC1jBMGAUW .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-k0XrtNUC1jBMGAUW .icon-shape p,#mermaid-svg-k0XrtNUC1jBMGAUW .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-k0XrtNUC1jBMGAUW .icon-shape .label rect,#mermaid-svg-k0XrtNUC1jBMGAUW .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-k0XrtNUC1jBMGAUW .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-k0XrtNUC1jBMGAUW .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-k0XrtNUC1jBMGAUW :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 当前位置
计算目标方向和距离
调整 v、ω
机器人运动

机器人越接近目标,位置不断变化,控制程序也会不断重新计算。

直到:

text 复制代码
distance < 允许误差

我们让:

javascript 复制代码
robot.v = 0;
robot.omega = 0;

机器人停止。

这当然还是一个非常简单的控制器,它不会避障,也不会寻找最优路径,甚至可能出现转弯不够平滑的问题。

但它已经让我们的 Demo 从:

"一个按照固定速度运动的小车动画"

变成了:

"一个能够根据当前位置和目标位置,不断调整自身运动的最小闭环机器人。"

后面学习 PID、路径规划、Nav2 时,我们实际上就是在不断升级这里的"怎么根据目标决定下一步怎么动"这个问题。

4. 完整代码

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>二维机器人运动模拟器</title>
    <style>
        :root{
            --accent:#1b9b9c;
            --accent-light:#e6f5f5;
            --bg:#f7f7f7;
            --text:#333333;
            --sub:#888888;
            --border:#e2e2e2;
        }
        *{box-sizing:border-box;}
        html,body{height:100%;margin:0;}
        body{
            font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
            color:var(--text);
            background:#ffffff;
        }
        .app{
            display:flex;
            height:100vh;
            width:100vw;
            overflow:hidden;
        }
        .canvas-wrap{
            flex:1;
            position:relative;
            background:var(--bg);
            min-width:0;
        }
        canvas{
            display:block;
            width:100%;
            height:100%;
            cursor:crosshair;
        }
        .panel{
            width:320px;
            flex:none;
            border-left:1px solid var(--border);
            padding:22px 20px;
            overflow-y:auto;
            background:#ffffff;
        }
        h1{
            font-size:16px;
            margin:0 0 4px;
            font-weight:600;
        }
        .sub{
            font-size:12px;
            color:var(--sub);
            margin-bottom:22px;
        }
        .section{margin-bottom:26px;}
        .section h2{
            font-size:13px;
            color:#666666;
            margin:0 0 12px;
            font-weight:600;
        }
        .status-grid{
            display:grid;
            grid-template-columns:1fr 1fr;
            gap:10px 12px;
        }
        .status-item{
            background:var(--accent-light);
            border-radius:6px;
            padding:9px 10px;
        }
        .status-item .label{
            font-size:11px;
            color:#4f8a8b;
        }
        .status-item .value{
            font-size:16px;
            font-weight:600;
            color:var(--accent);
            margin-top:3px;
            font-variant-numeric:tabular-nums;
        }
        .dpad{
            display:grid;
            grid-template-columns:repeat(3,1fr);
            grid-template-rows:repeat(3,1fr);
            gap:6px;
            max-width:210px;
        }
        .dpad button{
            aspect-ratio:1;
            border:1px solid var(--border);
            background:#ffffff;
            border-radius:6px;
            font-size:16px;
            cursor:pointer;
            color:var(--accent);
            transition:background 0.15s;
        }
        .dpad button:hover{background:var(--accent-light);}
        .dpad button:active{background:var(--accent);color:#ffffff;}
        .dpad .empty{visibility:hidden;}
        .slider-row{margin-bottom:16px;}
        .slider-row label{
            display:flex;
            justify-content:space-between;
            font-size:12px;
            color:#666666;
            margin-bottom:7px;
        }
        .slider-row input[type=range]{
            width:100%;
            accent-color:var(--accent);
        }
        .btn-row{display:flex;gap:8px;}
        .btn-row button{
            flex:1;
            padding:9px 0;
            border-radius:6px;
            border:1px solid var(--border);
            background:#ffffff;
            cursor:pointer;
            font-size:13px;
            color:var(--text);
        }
        .btn-row button.primary{
            background:var(--accent);
            border-color:var(--accent);
            color:#ffffff;
        }
        .btn-row button:hover{opacity:0.85;}
        .hint{
            font-size:11px;
            color:#999999;
            line-height:1.7;
            margin-top:12px;
        }
        @media (max-width:768px){
            .app{flex-direction:column;}
            .canvas-wrap{height:55vh;flex:none;}
            .panel{width:100%;height:45vh;border-left:none;border-top:1px solid var(--border);}
        }
    </style>
</head>
<body>
<div class="app">
    <div class="canvas-wrap">
        <canvas id="canvas"></canvas>
    </div>
    <div class="panel">
        <h1>二维机器人运动模拟器</h1>
        <div class="sub">当前状态 → 经过 dt → 运动方程 → 新状态</div>

        <div class="section">
            <h2>实时状态</h2>
            <div class="status-grid">
                <div class="status-item"><div class="label">X 位置</div><div class="value" id="valX">0.00 m</div></div>
                <div class="status-item"><div class="label">Y 位置</div><div class="value" id="valY">0.00 m</div></div>
                <div class="status-item"><div class="label">朝向 θ</div><div class="value" id="valTheta">30.0°</div></div>
                <div class="status-item"><div class="label">线速度 v</div><div class="value" id="valV">0.00 m/s</div></div>
                <div class="status-item"><div class="label">角速度 ω</div><div class="value" id="valOmega">0.00 rad/s</div></div>
                <div class="status-item"><div class="label">仿真时间</div><div class="value" id="valTime">0.00 s</div></div>
            </div>
        </div>

        <div class="section">
            <h2>手动控制</h2>
            <div class="dpad">
                <div class="empty"></div><button id="btnUp" title="前进">↑</button><div class="empty"></div>
                <button id="btnLeft" title="左转">←</button><button id="btnStop" title="停止">■</button><button id="btnRight" title="右转">→</button>
                <div class="empty"></div><button id="btnDown" title="后退">↓</button><div class="empty"></div>
            </div>
        </div>

        <div class="section">
            <h2>速度设置</h2>
            <div class="slider-row">
                <label><span>线速度 v</span><span id="sliderVVal">1.0 m/s</span></label>
                <input type="range" id="sliderV" min="0" max="3" step="0.1" value="1">
            </div>
            <div class="slider-row">
                <label><span>角速度 ω</span><span id="sliderOVal">1.0 rad/s</span></label>
                <input type="range" id="sliderO" min="0" max="2" step="0.1" value="1">
            </div>
        </div>

        <div class="section">
            <h2>仿真控制</h2>
            <div class="btn-row">
                <button class="primary" id="btnStart">开始</button>
                <button id="btnPause">暂停</button>
                <button id="btnReset">重置</button>
            </div>
            <div class="hint">点击左侧画布任意位置可设置目标点,机器人会自动转向并前往目标。</div>
        </div>
    </div>
</div>

<script>
    (function(){
        'use strict';

        var canvas = document.getElementById('canvas');
        var ctx = canvas.getContext('2d');

        var SCALE = 60;   // 1 米 = 60 像素
        var DT = 0.02;    // 固定仿真步长(秒)

        // ---------------- 机器人状态 ----------------
        var robot = { x: 0, y: 0, theta: 30 * Math.PI / 180, v: 0, omega: 0 };
        var simulationTime = 0;
        var running = false;

        var trail = [];        // 运动轨迹(世界坐标点)
        var trailAccum = 0;    // 轨迹采样计时器

        var target = null;     // 目标点 { x, y }(世界坐标)
        var autoTarget = false;// 是否处于"自动前往目标"模式
        var manualDir = null;  // 当前手动控制方向

        var originX = 0, originY = 0; // 世界原点在画布上的像素位置

        // ---------------- DOM 元素 ----------------
        var sliderV = document.getElementById('sliderV');
        var sliderO = document.getElementById('sliderO');
        var sliderVVal = document.getElementById('sliderVVal');
        var sliderOVal = document.getElementById('sliderOVal');

        var valX = document.getElementById('valX');
        var valY = document.getElementById('valY');
        var valTheta = document.getElementById('valTheta');
        var valV = document.getElementById('valV');
        var valOmega = document.getElementById('valOmega');
        var valTime = document.getElementById('valTime');

        sliderV.addEventListener('input', function () {
            sliderVVal.textContent = parseFloat(sliderV.value).toFixed(1) + ' m/s';
            reapplyManual();
        });
        sliderO.addEventListener('input', function () {
            sliderOVal.textContent = parseFloat(sliderO.value).toFixed(1) + ' rad/s';
            reapplyManual();
        });

        function reapplyManual() {
            // 拖动滑块时,如果当前正处于某个手动方向,实时应用新的速度设置
            if (manualDir) setManual(manualDir);
        }

        function setManual(dir) {
            autoTarget = false;
            var vSet = parseFloat(sliderV.value);
            var oSet = parseFloat(sliderO.value);

            if (dir === 'stop') {
                robot.v = 0;
                robot.omega = 0;
                manualDir = null;
                return;
            }

            manualDir = dir;
            if (dir === 'up') { robot.v = vSet; robot.omega = 0; }
            else if (dir === 'down') { robot.v = -vSet; robot.omega = 0; }
            else if (dir === 'left') { robot.v = vSet; robot.omega = oSet; }
            else if (dir === 'right') { robot.v = vSet; robot.omega = -oSet; }

            running = true; // 手动操作时自动启动仿真时钟,避免用户忘记点"开始"
        }

        document.getElementById('btnUp').addEventListener('click', function () { setManual('up'); });
        document.getElementById('btnDown').addEventListener('click', function () { setManual('down'); });
        document.getElementById('btnLeft').addEventListener('click', function () { setManual('left'); });
        document.getElementById('btnRight').addEventListener('click', function () { setManual('right'); });
        document.getElementById('btnStop').addEventListener('click', function () { setManual('stop'); });

        document.getElementById('btnStart').addEventListener('click', function () { running = true; });
        document.getElementById('btnPause').addEventListener('click', function () { running = false; });
        document.getElementById('btnReset').addEventListener('click', resetAll);

        function resetAll() {
            robot = { x: 0, y: 0, theta: 30 * Math.PI / 180, v: 0, omega: 0 };
            simulationTime = 0;
            running = false;
            trail = [];
            trailAccum = 0;
            target = null;
            autoTarget = false;
            manualDir = null;
            render();
        }

        // 点击画布 -> 设置目标点,机器人自动前往
        canvas.addEventListener('click', function (e) {
            var rect = canvas.getBoundingClientRect();
            var px = e.clientX - rect.left;
            var py = e.clientY - rect.top;
            var world = canvasToWorld(px, py);
            target = { x: world.x, y: world.y };
            autoTarget = true;
            manualDir = null;
            running = true;
        });

        function normalizeAngle(a) {
            // 将角度规范到 -PI ~ PI
            a = (a + Math.PI) % (2 * Math.PI);
            if (a < 0) a += 2 * Math.PI;
            return a - Math.PI;
        }

        // ---------------- 核心运动模型 ----------------
        function updateRobot(step) {
            // 若处于"自动前往目标"模式,先用简单比例控制算出 v、omega
            if (autoTarget && target) {
                var dx = target.x - robot.x;
                var dy = target.y - robot.y;
                var distance = Math.hypot(dx, dy);
                var targetAngle = Math.atan2(dy, dx);
                var angleError = normalizeAngle(targetAngle - robot.theta);

                var vMax = parseFloat(sliderV.value) || 1;
                var oMax = parseFloat(sliderO.value) || 1;
                var kOmega = 3.0; // 角度误差 -> 角速度 的比例系数
                var kV = 1.5;     // 距离 -> 线速度 的比例系数

                var omega = kOmega * angleError;
                omega = Math.max(-oMax, Math.min(oMax, omega));

                var v = kV * distance;
                v = Math.max(0, Math.min(vMax, v));

                // 朝向误差越大,越应该先转向、再前进
                var turnFactor = Math.max(0, 1 - Math.abs(angleError) / (Math.PI / 2));
                v *= turnFactor;

                if (distance < 0.08) {
                    v = 0;
                    omega = 0;
                    autoTarget = false; // 到达目标,退出自动模式;目标点本身保留在画面上
                }

                robot.v = v;
                robot.omega = omega;
            }

            // ---- 二维运动学方程(最简单的单车模型) ----
            // x_new     = x + v * cos(theta) * dt
            // y_new     = y + v * sin(theta) * dt
            // theta_new = theta + omega * dt
            robot.x += robot.v * Math.cos(robot.theta) * step;
            robot.y += robot.v * Math.sin(robot.theta) * step;
            robot.theta += robot.omega * step;
            simulationTime += step;

            // 轨迹采样(每 ~0.08s 记一个点,形成"疏密适中"的虚线轨迹)
            trailAccum += step;
            if (trailAccum >= 0.08) {
                trail.push({ x: robot.x, y: robot.y });
                trailAccum = 0;
                if (trail.length > 2000) trail.shift();
            }
        }

        // ---------------- 坐标系转换 ----------------
        // 世界坐标: Y 轴向上,原点在画布中央,单位为米
        // Canvas 坐标: Y 轴向下,单位为像素
        function resizeCanvas() {
            var wrap = canvas.parentElement;
            canvas.width = wrap.clientWidth;
            canvas.height = wrap.clientHeight;
            originX = canvas.width / 2;
            originY = canvas.height / 2;
        }
        window.addEventListener('resize', resizeCanvas);

        function worldToCanvas(x, y) {
            return { cx: originX + x * SCALE, cy: originY - y * SCALE };
        }
        function canvasToWorld(cx, cy) {
            return { x: (cx - originX) / SCALE, y: -(cy - originY) / SCALE };
        }

        // ---------------- 绘制 ----------------
        function drawGrid() {
            ctx.save();
            ctx.lineWidth = 1;
            ctx.strokeStyle = '#e2e2e2';
            var stepM = 0.5; // 网格间距(米)
            var stepPx = stepM * SCALE;

            var startX = originX % stepPx;
            for (var x = startX; x < canvas.width; x += stepPx) {
                ctx.beginPath();
                ctx.moveTo(x, 0);
                ctx.lineTo(x, canvas.height);
                ctx.stroke();
            }
            var startY = originY % stepPx;
            for (var y = startY; y < canvas.height; y += stepPx) {
                ctx.beginPath();
                ctx.moveTo(0, y);
                ctx.lineTo(canvas.width, y);
                ctx.stroke();
            }
            ctx.restore();

            // 主轴(比网格线深)
            ctx.save();
            ctx.strokeStyle = '#a3a3a3';
            ctx.lineWidth = 1.5;
            ctx.beginPath();
            ctx.moveTo(0, originY);
            ctx.lineTo(canvas.width, originY);
            ctx.stroke();
            ctx.beginPath();
            ctx.moveTo(originX, 0);
            ctx.lineTo(originX, canvas.height);
            ctx.stroke();
            ctx.restore();

            // 坐标轴标识
            ctx.fillStyle = '#888888';
            ctx.font = '13px -apple-system, sans-serif';
            ctx.fillText('X', canvas.width - 18, originY - 8);
            ctx.fillText('Y', originX + 8, 16);

            // 原点标记
            ctx.fillStyle = '#a3a3a3';
            ctx.beginPath();
            ctx.arc(originX, originY, 3, 0, Math.PI * 2);
            ctx.fill();
        }

        function drawTrail() {
            ctx.fillStyle = 'rgba(27, 155, 156, 0.35)';
            for (var i = 0; i < trail.length; i++) {
                var c = worldToCanvas(trail[i].x, trail[i].y);
                ctx.beginPath();
                ctx.arc(c.cx, c.cy, 2, 0, Math.PI * 2);
                ctx.fill();
            }
        }

        function drawTarget() {
            if (!target) return;
            var c = worldToCanvas(target.x, target.y);
            ctx.strokeStyle = '#1b9b9c';
            ctx.lineWidth = 2;
            ctx.beginPath();
            ctx.arc(c.cx, c.cy, 9, 0, Math.PI * 2);
            ctx.stroke();
            ctx.beginPath();
            ctx.moveTo(c.cx - 4, c.cy);
            ctx.lineTo(c.cx + 4, c.cy);
            ctx.stroke();
            ctx.beginPath();
            ctx.moveTo(c.cx, c.cy - 4);
            ctx.lineTo(c.cx, c.cy + 4);
            ctx.stroke();
        }

        // 用 Canvas Path 手绘"导航箭头",局部坐标系中默认朝向 +x 方向
        var ARROW_LOCAL = [
            { x: 0.22, y: 0 },
            { x: -0.10, y: -0.13 },
            { x: -0.05, y: 0 },
            { x: -0.10, y: 0.13 }
        ];

        function drawRobot() {
            var cosT = Math.cos(robot.theta);
            var sinT = Math.sin(robot.theta);

            var canvasPts = ARROW_LOCAL.map(function (p) {
                // 先按 theta 旋转,再平移到机器人位置(世界坐标),最后转换到画布坐标
                var wx = robot.x + (p.x * cosT - p.y * sinT);
                var wy = robot.y + (p.x * sinT + p.y * cosT);
                return worldToCanvas(wx, wy);
            });

            ctx.save();
            ctx.fillStyle = '#1b9b9c';
            ctx.strokeStyle = '#157678';
            ctx.lineWidth = 1.5;
            ctx.lineJoin = 'round';
            ctx.beginPath();
            ctx.moveTo(canvasPts[0].cx, canvasPts[0].cy);
            for (var i = 1; i < canvasPts.length; i++) {
                ctx.lineTo(canvasPts[i].cx, canvasPts[i].cy);
            }
            ctx.closePath();
            ctx.fill();
            ctx.stroke();
            ctx.restore();
        }

        function updateStatusPanel() {
            valX.textContent = robot.x.toFixed(2) + ' m';
            valY.textContent = robot.y.toFixed(2) + ' m';
            valTheta.textContent = (robot.theta * 180 / Math.PI).toFixed(1) + '°';
            valV.textContent = robot.v.toFixed(2) + ' m/s';
            valOmega.textContent = robot.omega.toFixed(2) + ' rad/s';
            valTime.textContent = simulationTime.toFixed(2) + ' s';
        }

        function render() {
            ctx.clearRect(0, 0, canvas.width, canvas.height);
            drawGrid();
            drawTrail();
            drawTarget();
            drawRobot();
            updateStatusPanel();
        }

        // ---------------- 主循环(固定步长积分,避免帧率影响物理结果) ----------------
        var lastTs = null;
        var accumulator = 0;

        function loop(ts) {
            if (lastTs === null) lastTs = ts;
            var frameDt = (ts - lastTs) / 1000;
            lastTs = ts;
            if (frameDt > 0.25) frameDt = 0.25; // 防止切后台/卡顿后时间跳跃过大

            if (running) {
                accumulator += frameDt;
                while (accumulator >= DT) {
                    updateRobot(DT);
                    accumulator -= DT;
                }
            }
            render();
            requestAnimationFrame(loop);
        }

        // ---------------- 初始化 ----------------
        resizeCanvas();
        render(); // 未点击"开始"时,首帧也要立即显示网格、坐标轴与机器人箭头
        requestAnimationFrame(loop);
    })();
</script>
</body>
</html>

六、从这个最小 Demo 出发,我们已经实现了什么?

到这里,我们做出来的东西其实非常简单。

没有 ROS 2,没有 Gazebo,没有 LiDAR,也没有 SLAM。画布上甚至只有一个代表机器人的导航箭头。

但这个 Demo 的意义并不在于"功能有多少",而在于我们第一次亲手把前面学到的机器人运动概念真正运行了起来。

1. 我们第一次把机器人变成了"状态"

在程序里,机器人不再首先是一个模型或者一张图片,而是一组数据:

text 复制代码
x、y
→ 我在哪里?

θ
→ 我朝哪里?

v
→ 我移动多快?

ω
→ 我旋转多快?

Δt
→ 仿真向前推进了多少时间?

画布上的导航箭头只是这些状态的可视化。

真正决定机器人运动的是背后的数据。

2. 我们第一次让数学公式变成了运动

整个 Demo 最核心的其实只有:

text 复制代码
x_new = x + v × cos(θ) × Δt

y_new = y + v × sin(θ) × Δt

θ_new = θ + ω × Δt

程序不断重复:

text 复制代码
读取当前状态
      ↓
推进 Δt
      ↓
计算新的 x、y、θ
      ↓
重新绘制
      ↓
继续下一次计算

于是几个看起来很普通的数学公式,最终变成了屏幕上真实可见的:

直行、转弯、旋转和运动轨迹。

这就是我们这一篇真正想实现的东西。

3. 从手动控制到"给机器人一个目标"

最开始,我们直接控制:

text 复制代码
v
ω

相当于不断告诉机器人:

"往前走。"
"向左转。"

后来我们又增加了目标点:

text 复制代码
targetX
targetY

这时候,我们不再告诉机器人每一步应该怎么走,而是告诉它:

"你去这里。"

程序自己计算目标方向、距离和朝向误差,再不断调整 vω

虽然这个控制器非常简单,但已经出现了机器人系统里非常重要的闭环:
#mermaid-svg-fCl2jIYKoXcshQE9{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-fCl2jIYKoXcshQE9 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-fCl2jIYKoXcshQE9 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-fCl2jIYKoXcshQE9 .error-icon{fill:#552222;}#mermaid-svg-fCl2jIYKoXcshQE9 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-fCl2jIYKoXcshQE9 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-fCl2jIYKoXcshQE9 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-fCl2jIYKoXcshQE9 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-fCl2jIYKoXcshQE9 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-fCl2jIYKoXcshQE9 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-fCl2jIYKoXcshQE9 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-fCl2jIYKoXcshQE9 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-fCl2jIYKoXcshQE9 .marker.cross{stroke:#333333;}#mermaid-svg-fCl2jIYKoXcshQE9 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-fCl2jIYKoXcshQE9 p{margin:0;}#mermaid-svg-fCl2jIYKoXcshQE9 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-fCl2jIYKoXcshQE9 .cluster-label text{fill:#333;}#mermaid-svg-fCl2jIYKoXcshQE9 .cluster-label span{color:#333;}#mermaid-svg-fCl2jIYKoXcshQE9 .cluster-label span p{background-color:transparent;}#mermaid-svg-fCl2jIYKoXcshQE9 .label text,#mermaid-svg-fCl2jIYKoXcshQE9 span{fill:#333;color:#333;}#mermaid-svg-fCl2jIYKoXcshQE9 .node rect,#mermaid-svg-fCl2jIYKoXcshQE9 .node circle,#mermaid-svg-fCl2jIYKoXcshQE9 .node ellipse,#mermaid-svg-fCl2jIYKoXcshQE9 .node polygon,#mermaid-svg-fCl2jIYKoXcshQE9 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-fCl2jIYKoXcshQE9 .rough-node .label text,#mermaid-svg-fCl2jIYKoXcshQE9 .node .label text,#mermaid-svg-fCl2jIYKoXcshQE9 .image-shape .label,#mermaid-svg-fCl2jIYKoXcshQE9 .icon-shape .label{text-anchor:middle;}#mermaid-svg-fCl2jIYKoXcshQE9 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-fCl2jIYKoXcshQE9 .rough-node .label,#mermaid-svg-fCl2jIYKoXcshQE9 .node .label,#mermaid-svg-fCl2jIYKoXcshQE9 .image-shape .label,#mermaid-svg-fCl2jIYKoXcshQE9 .icon-shape .label{text-align:center;}#mermaid-svg-fCl2jIYKoXcshQE9 .node.clickable{cursor:pointer;}#mermaid-svg-fCl2jIYKoXcshQE9 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-fCl2jIYKoXcshQE9 .arrowheadPath{fill:#333333;}#mermaid-svg-fCl2jIYKoXcshQE9 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-fCl2jIYKoXcshQE9 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-fCl2jIYKoXcshQE9 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fCl2jIYKoXcshQE9 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-fCl2jIYKoXcshQE9 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fCl2jIYKoXcshQE9 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-fCl2jIYKoXcshQE9 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-fCl2jIYKoXcshQE9 .cluster text{fill:#333;}#mermaid-svg-fCl2jIYKoXcshQE9 .cluster span{color:#333;}#mermaid-svg-fCl2jIYKoXcshQE9 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-fCl2jIYKoXcshQE9 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-fCl2jIYKoXcshQE9 rect.text{fill:none;stroke-width:0;}#mermaid-svg-fCl2jIYKoXcshQE9 .icon-shape,#mermaid-svg-fCl2jIYKoXcshQE9 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fCl2jIYKoXcshQE9 .icon-shape p,#mermaid-svg-fCl2jIYKoXcshQE9 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-fCl2jIYKoXcshQE9 .icon-shape .label rect,#mermaid-svg-fCl2jIYKoXcshQE9 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fCl2jIYKoXcshQE9 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-fCl2jIYKoXcshQE9 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-fCl2jIYKoXcshQE9 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 当前位置
与目标比较
计算控制量
机器人运动

这也是后面学习 PID、轨迹跟踪、路径规划和 Nav2 时会不断升级的一条主线。

4. 我们也知道了这个 Demo 还缺什么

现在这个机器人生活在一个非常理想的世界里:

text 复制代码
没有质量
没有惯性
没有摩擦
没有轮胎打滑
没有碰撞

没有 Encoder
没有 IMU
没有 LiDAR
没有 Camera

没有 SLAM
没有地图
没有障碍物
没有路径规划

所以它距离一台真正的机器人还很远。

但这反而给我们留下了一条非常清晰的升级路线:

先做最简单的模型,再一次只增加一个真实因素,观察机器人系统发生什么变化。

以后我们可以给它加编码器,看看机器人怎么知道轮子转了多少;加入 IMU,观察加速度、角速度、噪声和漂移;加入 LiDAR,让机器人第一次"看到"障碍物;再加入 SLAM、路径规划和控制,让它逐渐具备真正的自主能力。

最终,我们还会把这些自己实现过的基础原理带进 ROS 2、Gazebo 等专业机器人开发环境。

到那时,再看到一个机器人在 Gazebo 中运动,我们看到的就不再只是:

"这个软件可以让机器人跑起来。"

而会知道它背后至少存在这样一条最基础的逻辑:

状态 → 时间推进 → 数学/物理计算 → 新状态 → 传感器反馈 → 控制 → 再次计算。

这就是"从零实现"真正的意义。

我们不是为了重新造一个 Gazebo,而是为了在使用 Gazebo 之前,先知道这个黑盒里面最基础的齿轮是怎样转起来的。

七、关于八荒启

八荒启是一家专注于交互体验产品与解决方案的品牌,持续探索交互技术在教育教学、产品展示、过程模拟、操作训练和数据可视化等场景中的应用。

我们不仅分享技术实现,也持续创作和沉淀交互动画、数字作品、开发教程、项目案例与行业解决方案,希望通过交互技术,让复杂事物变得更加容易理解、探索、操作和创造。

八荒启,专为交互动画而生

让复杂事物可探索、可操作、可反馈

如果你正在寻找交互作品、学习相关技术,或者希望把一个想法转化为可实际操作的交互项目,欢迎访问八荒启官网了解更多案例与服务。

  • 官方网站:https://bahuangqi.com
  • 主要内容:交互动画、3D 可视化、教育互动、仿真模拟与技术教程
  • 定制服务:可通过官网提交需求或联系人工客服进行评估

感谢阅读。如果本文对你有所帮助,欢迎点赞、收藏和关注,我们会继续分享更多交互作品与项目实践。

相关推荐
鲁邦通物联网4 小时前
机器人梯控如何区分电梯门全开与半开状态
人工智能·机器人·机器人梯控·agv梯控·机器人乘梯·机器人自主乘梯·agv机器人梯控
DogDaoDao6 小时前
HYPERmotion 深度解析:当人形机器人学会“自己想招“——LLM 规划 + RL 技能库 + 全身优化的混合行为规划框架
人工智能·机器人·人形机器人·运动轨迹·自由度·运动估计·hypermotion
拓维运动科技7 小时前
机器人重复工况测试平台:零部件耐久验证与样机迭代的标准化路径
机器人·六自由度运动模拟平台·非标定制机器人动态测试平台·机器人性能测试平台·模拟机器人性能工况
八荒启·交互动画8 小时前
机器人仿真实践(第0卷)——002机器人定义与发展史:数学/物理原理
机器人·八荒启-交互动画
xwz小王子8 小时前
【通用世界模型】把机器人动作变成像素流!NVIDIA 提出 Hydra-0:跨人类、单臂与双臂统一预测和控制
数码相机·机器人
星云API技术支持8 小时前
企业微信二次开发:文件异步上传、媒体消息与回调处理的组合实践
机器人·企业微信
星云API技术支持9 小时前
企业微信二次开发完整方案:消息收发、外部群机器人、自动回复、Webhook 一套跑通
机器人·yapi·企业微信
yuewell_ai1 天前
机器人的方向感从哪来-定位传感器一次讲透
机器人
风合星语1 天前
2026 机器人工程实例(五):让 Allegro Hand 在 MuJoCo 中完成接触抓取——抓取状态机、稳定抬升与受控释放
c++·python·机器人·仿真