定向增发会稀释股权吗会让物体,,,,,,

稀释硫酸遇水有危险吗?_百度知道第 1 章 物体的运动
1.1 让物体沿水平方向运动
1.2 通过键盘控制物体的运动
1.3 让物体沿任意方向运动
1.4 在物体运动中加入重力
1.5 物体随机飞溅运动
1.6 让物体进行圆周运动
1.7 [ 进阶 ] 微分方程式及其数值解法
1.1 让物体沿水平方向运动
 匀速直线运动、x+=v;、v = -v      
物体运动中最基本的是直线运动。在本小节,我们就来一起学习 RPG、射击、动作、解谜等所有游戏类型中最基本的匀速直线运动吧。
说到物体的基本运动,请试想一下物体以一定速度沿直线运动的情况。没错,这种物体以固定速度行进的直线运动,称为匀速直线运动。本小节就来讲解如何让物体进行匀速直线运动。
图 1-1-1 匀速直线运动的程序
沿水平方向运动的程序
示例程序 Movement_1_1.cpp 是物体单纯地沿水平方向运动的程序。虽然代码有点长,但大部分都是为了操作 DirectX 以在画面上演示运动,真正决定物体运动的只有以下部分(代码清单 1-1-1)。
代码清单 1-1-1 决定物体运动的处理(Movement_1_1.cpp 片段)
| int InitCharacter( void )
// 只在程序开始时调用一次
// 物体的初始位置
// 物体在x方向的速度
| int MoveCharacter( void )
// 每帧调用一次
// 实际移动物体
下面对这部分代码详细说明一下。首先,InitCharacter 函数是一个只在程序初始化时被调用一次的函数,用于设定物体的初始位置及 x 方向的速度。初始位置
// 物体的初始位置
为 0 代表物体开始位于画面最左端。x 方向的速度
// 物体在x方向的速度
被设定为 3。
之后的 MoveCharacter 函数是一个在画面切换时,即每帧被调用的函数。这个函数进行的处理为
// 实际移动物体
即向水平方向的位置 x 加入速度 v。在这里,v 在初始设定中为 3,所以每次 MoveCharacter 被调用(即每帧)时 x 坐标都会增加 3。一般来说到下一个画面切换的时间即帧速率为
秒,因此程序中物体会以每秒 180 像素的速度向右侧移动(参考图 1-1-2)。
图 1-1-2 物体以每秒 180 像素的速度移动
那么大家是不是对上述内容都理解了呢?来做个实验吧。首先让我们改变一下物体的运动速度。比如将 InitCharacter 函数内的
// 物体在x方向的速度
// 物体在x方向的速度
这样一来物体的速度将变为原来的 ,即物体将比原来更加缓慢地移动(Movement_1_1a.cpp)。
然后让我们在不改变水平运动方式的前提下,尝试把物体动作改造得稍微复杂一点。原程序中,即使物体到达画面边缘,也不会停止,而是会直接移出画面。让我们来将其修改为:当物体碰到画面边缘,会沿反方向弹回。为此,我们分别对 InitCharacter 函数及 MoveCharacter 函数做以下改动。
代码清单 1-1-2 修改为物体碰到画面边缘时折回(Movement_1_1b.cpp 片段)
| int InitCharacter( void )
// 只在程序开始时调用一次
// 物体的初始位置
// 物体在x方向的速度
| int MoveCharacter( void )
// 每帧调用一次
// 实际移动物体
if ( x & VIEW_WIDTH - CHAR_WIDTH ) {
// 物体碰到右端
x = VIEW_WIDTH - CHAR_WIDTH;
// 重设坐标为画面边缘
if ( x & 0 ) {
MoveCharacter 函数增加了一些内容,不过还是从 InitCharacter 函数开始按顺序说明。首先,InitCharacter 函数中 x 方向的速度
// 物体在x方向的速度
从之前的 3 增加到了 10。虽然速度变快了,但是由于新程序会让物体在画面边缘弹回,因此不必担心物体会一下子从画面中消失。然后在 MoveCharacter 函数中使用了 2 个 if 语句,追加了物体碰到画面左右端时弹回的处理。下面以画面右端的处理为例进行说明。
if ( x & VIEW_WIDTH - CHAR_WIDTH ) {
// 物体碰到右端
这一行用来判断物体是否碰到画面右端。其中 VIEW_WIDTH 是画面的宽度,即画面右端的 x 坐标。那么有人可能会想,判断物体是否碰到画面右端,只要比较一下物体的 x 坐标和 VIEW_WIDTH 不就好了吗?事实上并没这么简单,因为电脑在绘制 2D 物体时,一般会以物体的左上角作为物体坐标的原点,如果只是使物体的 x 坐标不超出 VIEW_WIDTH,会让物体完全移出画面之外(参考图 1-1-3 左)。因此当物体碰到画面右端时,为了使程序做出“已经碰到”的判断,应当从画面右端的 x 坐标 VIEW_WIDTH 中,减去物体本身的大小 CHAR_WIDTH,然后再与物体的 x 坐标比较(参考图 1-1-3 右)。上面的讲解可能有点复杂,希望大家能正确理解。
图 1-1-3 物体到达画面右端时的计算
然后,当物体碰到边缘时运行下面这行处理。
这行代码负责对物体的弹回动作进行处理。具体来说就是反转速度的符号。比如程序中速度的初始值为 10,当物体碰到画面边缘时速度将变成 -10,再碰到另一边时从 -10 变回 10,因此物体会沿反方向运动。可能有人会有疑问,既然这个 if 语句中判断的对象是画面右端,我们已经知道物体是向右运动的,同时物体在向右运动的过程中碰到边缘时的 v 必定为 10,将 v 变为 -10 后物体就会向左运动。那么将
不是更加容易理解吗?这样的硬编码是不好的,因为如果这样写,我们下次要更改移动速度时,就不得不先在速度的初始化函数 InitCharacter 中找到下述设定项并进行更改,
// 物体在x方向的速度
但是只更改这里的初始设定程序仍然无法正常工作。比如,假设我们在此处将初始速度减半至 5,那么物体碰到画面右端的瞬间,其速度将突然倍增为 -10 并进行反向运动,这就违背了我们的意图。如果要扩展这个程序,比如新增加速度的处理等,这样硬编码的速度会让程序完全无法修改。因此考虑到程序的可维护性及扩展性,需要将速度书写为
最后说明下面这行。
x = VIEW_WIDTH - CHAR_WIDTH;
// 重设坐标为画面边缘
正如注释中所写的那样,这行是为了将物体强制移动到正好与边缘接触的位置。之所以这样处理,是由于判断物体是否碰到边缘的 if 语句,是在物体实际已经碰到了边缘之后才执行的。比如,假设物体在前一帧中距离边缘还有 1 像素,且在当前帧中的速度达到了 10,那么当物体实际已经超出边缘 9 像素时,if 语句才会执行。这样显然会有问题。因此无论物体在与边缘接触的瞬间是否会超出,我们都将物体强制设置回与边缘正好接触的位置(参考图 1-1-3 右)。注意观察就会发现,重设物体位置的语句
x = VIEW_WIDTH - CHAR_WIDTH;
// 重设坐标为画面边缘
与检测物体是否碰到右端的 if 语句
if ( x & VIEW_WIDTH - CHAR_WIDTH ) {
// 物体碰到右端
的区别只是将语句中的大于号(>)更改为了等号(=)。换个角度思考,这里的处理就相当于一旦做出物体接触到边缘的判断,就立即将物体强制移动至判断开始发生的位置。
至此,我们对物体碰到画面右端时弹回的处理进行了说明。这部分处理之后,代码中还有画面左端的弹回处理,处理内容与画面右端是一样的,此处不再赘述。
1.2 通过键盘控制物体的运动
 键盘输入、斜方向移动、勾股定理       前半部分  后半部分
物体运动中最基本的是直线运动。在本小节,我们就来一起学习 RPG、射击、动作、解谜等所有游戏类型中最基本的匀速直线运动吧。
用户通过键盘输入控制物体的运动,是无法简单地通过直线运动实现的。本小节就来讲解包括斜方向运动在内的可通过键盘控制的物体运动。
通过用户输入来控制物体运动是所有游戏的基础,从实用性来讲是极其重要的。
图 1-2-1 通过键盘输入控制物体运动的程序
通过键盘输入控制物体运动的程序
示例程序 Movement_2_1.cpp 是一个通过键盘输入控制物体运动的简单程序。这个程序有点类似于一个极为简陋的射击游戏,按键盘的左右方向键可以移动物体。
决定物体移动的代码如下所示(代码清单 1-2-1)。
代码清单 1-2-1 根据键盘输入左右移动物体的处理(Movement_2_1.cpp 片段)
| int InitCharacter( void )
// 只在程序开始时调用一次
// 物体的初始位置
x = ( VIEW_WIDTH - CHAR_WIDTH ) / 2.0f;
y = ( VIEW_HEIGHT - CHAR_HEIGHT ) / 2.0f;
| int MoveCharacter( void )
// 每帧调用一次
// 左方向键被按下时向左移动
if ( GetAsyncKeyState( VK_LEFT ) ) {
x -= PLAYER_VEL;
if ( x & 0.0f ) {
// 右方向键被按下时向右移动
if ( GetAsyncKeyState( VK_RIGHT ) ) {
x += PLAYER_VEL;
if ( x & ( float )( VIEW_WIDTH - CHAR_WIDTH ) ) {
x = ( float )( VIEW_WIDTH - CHAR_WIDTH );
在初始化函数 InitCharacter 中,定义了物体的初始位置,如下所示。
// 物体的初始位置
x = ( VIEW_WIDTH - CHAR_WIDTH ) / 2.0f;
y = ( VIEW_HEIGHT - CHAR_HEIGHT ) / 2.0f;
即同时指定物体在水平方向和垂直方向的值,让物体的初始位置位于画面的中央。
然后在每帧调用的 MoveCharacter 函数中实现了一个主要功能:当一些特殊按键被按下时,物体向特定的方向移动;而除此以外的按键被按下时,则不做任何处理。因此必须要有一个按键检测机制来判断当前特殊按键是否被按下了。为此程序中调用了一个名为 GetAsyncKeyState 的函数。这个函数属于 Windows API 的一部分,即使没有 DirectX 也可以使用。如果想通过 DirectX 检测键盘输入等用户输入,需要使用 DirectInput 函数,而此处要检测的输入比较简单,仅使用 Windows API 就足够了。通过 GetAsyncKeyState 函数检测左方向键是否被按下,可以参考下面这行代码。
if ( GetAsyncKeyState( VK_LEFT ) ) {
同理,检测右方向键时的代码为
if ( GetAsyncKeyState( VK_RIGHT ) ) {
接着看 MoveCharacter 的内部,当左方向键被按下时,
x -= PLAYER_VEL;
物体的 x 坐标会减去常数 PLAYER_VEL。对 x 坐标做减法就等同于物体向画面的左方移动。但除此之外,还会进行下面这一处理。
if ( x & 0.0f ) {
这个处理会使物体到达画面左端时不再向左移动。
右方向键被按下(即满足条件 if ( GetAsyncKeyState( VK_RIGHT ) ))时的处理为
x += PLAYER_VEL;
物体的 x 坐标将增加 PLAYER_VEL,物体会向右移动。此处也存在一个特殊处理,即
if ( x & ( float )( VIEW_WIDTH - CHAR_WIDTH ) ) {
x = ( float )( VIEW_WIDTH - CHAR_WIDTH );
这样一来,物体在到达画面右端时,也不会再向右移动。另外,此处还进行了一个 (float) 强制类型转换,这是由于表示坐标的变量 x 使用的是 float 类型,而 VIEW_WIDTH 和 CHAR_WIDTH 则被定义为了 int 型的常数,为了比较和赋值,需要提前统一为 float 型。
多个按键的输入
接下来让我们尝试使物体不只可以左右方向移动,还可以上下方向移动(Movement_2_1a.cpp)。为此,对 MoveCharacter 函数做如下变更(代码清单 1-2-2)。
代码清单 1-2-2 使物体可以根据按键输入上下方向运动(Movement_2_1a.cpp 片段)
| int MoveCharacter( void )
// 每帧调用一次
// 左方向键被按下时向左移动---------------------------------------------┐
if ( GetAsyncKeyState( VK_LEFT ) ) {
x -= PLAYER_VEL;
if ( x & 0.0f ) {
}-----------------------------------------------------------------------┘
// 右方向键被按下时向右移动---------------------------------------------┐
if ( GetAsyncKeyState( VK_RIGHT ) ) {
x += PLAYER_VEL;
if ( x & ( float )( VIEW_WIDTH - CHAR_WIDTH ) ) {
x = ( float )( VIEW_WIDTH - CHAR_WIDTH );
}-----------------------------------------------------------------------┘
// 上方向键被按下时向上移动---------------------------------------------┐
if ( GetAsyncKeyState( VK_UP ) ) {
y -= PLAYER_VEL;
if ( y & 0.0f ){
}-----------------------------------------------------------------------┘
// 下方向键被按下时向下移动----------------------------------------------┐
if ( GetAsyncKeyState( VK_DOWN ) ) {
y += PLAYER_VEL;
if ( y & ( float )( VIEW_HEIGHT
- CHAR_HEIGHT
y = ( float )( VIEW_HEIGHT - CHAR_HEIGHT );
}-----------------------------------------------------------------------┘
代码行数增加了不少,不过基本上只是把修改前对 x 坐标的处理照搬到了 y 坐标上而已。 具体说来代码段③的功能是,在上方向键被按下时从 y 坐标减去 PLAYER_VEL,并使物体在 到达画面上端后不再向上移动。④的部分同理,在下方向键被按下时向 y 坐标增加 PLAYER_VEL,并使物体在到达画面下端后不再向下移动。
这样一来很多人都会产生疑问,如果同时按下多个按键物体将会如何移动呢?比如物体只在水平方向移动时,同时按下左右方向键(可以看作停止运动的操作),物体会停止移动,这也比较符合人们一般的思维习惯。而如果物体不仅左右移动而且上下移动,那么就可以将左右按键与上下按键进行一些组合,可能性也就更多了,比如同时按上方向键与左方向键时物体会如何运动呢?在目前的程序里,如果同时按上方向键与左方向键,即代码中的条件①与条件③同时满足时,物体将向画面的左上角移动(参考图 1-2-2)。
想必物体的上述运动大家都能够想象得出来,但问题是物体的速度会如何变化呢?比如上方向键与左方向键同时被按下时,
x -= PLAYER_VEL;
图 1-2-2 上方向键与左方向键同时被按下时物体向左上角移动
y -= PLAYER_VEL;
的处理会同时进行,那么物体的速度显然要比单方向的 PLAYER_VEL 更快,因为物体既在 x 方向上以 PLAYER_VEL 的速度运动,同时又在 y 方向上以 PLAYER_VEL 的速度运动。这种情况下,物体沿斜方向移动的速度究竟是多少呢?
首先可以明确的是,上述情况下物体的速度肯定要比 PLAYER_VEL 更快。但是由于物体不是在同一方向(如 x 方向)上以 2 倍的 PLAYER_VEL 速度运动,而是在 x 方向和 y 方向上 分别以 PLAYER_VEL 的速度运动,所以恐怕物体的真正速度又比 2 倍的 PLAYER_VEL 要慢。于是可以得到下面的不等式。
vp & v & 2vp
这里 vp 代表 PLAYER_VEL,即只按一个方向键时物体的速度。v 则代表两个方向键同时被按下时物体的速度。
但是,仅有这个不等式,还是无法知道斜方向上的速度 v 究竟是多少,或者说 v 与 vp 之间究竟有怎样的关系。这个时候,我们就需要使用数学上的勾股定理了。简单地说,勾股定理就是能通过直角三角形的两条边的长度,计算出剩下一条边的长度的定理。而这个定理在游戏中,以计算直角三角形的斜边的长度居多。假设直角三角形的斜边长为 c,两直角边长分别为 a、b,那么公式为
c2 = a2 +b2
参考图 1-2-3。
图 1-2-3 通过两边的长度求剩余一边的长度
而物体同时在 x 方向和 y 方向上以速度 vp 运动的情况下,也可以套用勾股定理。首先,让我们只考虑一帧内物体运动的距离。此时,由于物体在一帧内在 x 方向与 y 方向上移动的速度均为 vp(PLAYER_VEL),那么以这两个移动距离各自作为三角形的一边,就可以绘制出一个等腰三角形。而由于 x 方向与 y 方向互为直角,结果物体的最终速度 v 就是这个等腰直角三角形的斜边(参考图 1-2-4)。
图 1-2-4 通过 x 方向与 y 方向的速度求速度 v
套用勾股定理就是
等式右边相加得到
因为我们最终要得到的是 v 而不是 v2,所以等式两边都取平方根,得到
由于我们最终要求的速度不会是负值,所以
即同时按下左方向键和上方向键时,物体既不会向水平方向移动,也不会向垂直方向移动,而是会以
的速度向左上方向移动。 约等于 1.41421,因此物体在斜方向的运动速度约为平时的 1.4 倍。在真正的游戏中(特别是 2D 射击类游戏),“斜方向的移动速度为原速度的 1.4 倍”这种情况一般是允许出现的(视实际情况也会有特例),大家随便想想就能列举出不少这种沿斜方向移动时速度变快的游戏。虽然以
倍的速度沿斜方向移动没有什么问题,但既然做到了这种程度,我们就来尝试一下如何让斜方向的移动速度保持不变吧。如 Movement_2_1b.cpp 所示,我们对 MoveCharacter 函数做了以下修改(代码清单 1-2-3)。
代码清单 1-2-3 使斜方向的移动速度保持不变(Movement_2_1b.cpp 片段)
| #define ROOT2
| int MoveCharacter( void ) // 每帧调用一次
bLeftKey, bRightK
bUpKey, bDownK
bLeftKey = GetAsyncKeyState( VK_LEFT );
bRightKey = GetAsyncKeyState( VK_RIGHT );
bUpKey = GetAsyncKeyState( VK_UP );
bDownKey = GetAsyncKeyState( VK_DOWN );
// 左方向键被按下时向左移动------------------------------------------┐
if ( bLeftKey ) {
if ( bUpKey || bDownKey ) {
x -= PLAYER_VEL / ROOT2;
x -= PLAYER_VEL;
if ( x & 0 ) {
}--------------------------------------------------------------------┘
// 右方向键被按下时向右移动------------------------------------------┐
if ( bRightKey ) {
if ( bUpKey || bDownKey ) {
x += PLAYER_VEL / ROOT2;
x += PLAYER_VEL;
if ( x &= ( float )( VIEW_WIDTH - CHAR_WIDTH ) ) {
x = ( float )( VIEW_WIDTH - CHAR_WIDTH );
}--------------------------------------------------------------------┘
// 上方向键被按下时向上移动------------------------------------------┐
if ( bUpKey ) {
if ( bLeftKey || bRightKey ) {
y -= PLAYER_VEL / ROOT2;
y -= PLAYER_VEL;
if ( y & 0 ) {
}--------------------------------------------------------------------┘
// 下方向键被按下时向下移动------------------------------------------┐
if ( bDownKey ) {
if ( bLeftKey || bRightKey ) {
y += PLAYER_VEL / ROOT2;
y += PLAYER_VEL;
if ( y &= ( float )( VIEW_HEIGHT - CHAR_HEIGHT ) ) {
y = ( float )( VIEW_HEIGHT - CHAR_HEIGHT );
}--------------------------------------------------------------------┘
这里首先定义了 bLeftKey、bRightKey、bUpKey、bDownKey 四个变量,分别存放左、右、上、下方向键当前是否被按下这一信息。下面主要以左方向键被按下时向左移动的代码段①为例来详细说明。在这部分中,首先检查左方向键有没有被按下。
if ( bLeftKey ) {
当左方向键被按下时,再接着检查上下方向键有没有被按下。
if ( bUpKey || bDownKey ) {
如果在左方向键被按下的同时,又有上下方向键中的一个被按下,那么程序就会认为物体在向斜方向移动并执行以下语句。
x -= PLAYER_VEL / ROOT2;
如果物体在 x 方向和 y 方向的移动速度仍然是 PLAYER_VEL,那么斜方向的运动速度就为原来的
倍,因此这里通过将原速度乘以 ,斜方向的运动速度就会仍然保持 PLAYER_VEL 不变(参考图 1-2-5)。
图 1-2-5 重置物体在水平及垂直方向的移动速度为 ,使物体在斜方向上的移动速度保持不变
而如果左方向键被按下,上下方向键都没有被按下时,物体将仍然以速度 PLAYER_VEL 向水平方向运动。
x -= PLAYER_VEL;
通过将上述处理应用到右方向键、上方向键和下方向键,最终就可以让物体在水平、垂直、斜方向上的移动速度都固定为 PLAYER_VEL。但是上面的程序只能应对两个方向键被同时按下的情况,而没有考虑到同时按下三个方向键的情况。比如左、上、下三个方向键同时被按下时,上下的运动被抵消,物体不会向垂直方向运动,只会向左方向运动,但是由于上下方向键处于被按下的状态,所以物体在左方向的运动速度会变为平时的 倍。也就是说,我们无意中创造了一个游戏秘技,可以通过特殊操作让物体运动得比平时更慢,即同时按“上与下”或“左与右”这样相反的方向键。但是这仅限于可以不受限制地同时按任意个键盘的方向键或其他按键的环境。而考虑到多数游戏使用的都是游戏手柄或游戏摇杆等,这些设备从物理上就无法同时输入相反的方向,自然也就不会存在这一秘技,因此这里省略了三个方向键同时输入时的程序处理。如果对这部分程序心存疑虑,那就不妨自己努力打造一个不会轻易被秘技搞坏的游戏吧。
1.3 让物体沿任意方向运动
 三角函数、正弦、余弦、弧度      
我们已经学习了让物体沿水平、斜方向运动。这次让我们再进一步,来学习如何让物体沿任意方向运动吧。
接下来,我们一起学习如何让物体以任意速度、沿任意方向运动。1.1 节介绍了如何让物体沿水平方向运动,1.2 节的后半部分介绍了如何让物体沿 45 度角的斜方向运动,让我们在此基础上进一步拓展,让物体在任意方向以任意速度运动。还没有实践过物体沿水平方向及 45 度角方向运动的读者,建议先完成前面两小节的程序再开始本小节的学习。
图 1-3-1 使物体沿任意方向以任意速度运动的程序
假设物体的运动速度为 v,那么物体沿任意方向的运动一般都可以被分拆为在 x 轴、y 轴两个方向上的运动。假设分拆后物体在 x 方向上的速度为 vx,在 y 方向上的速度为 vy,物体运动方向与 x 轴的夹角为 θ(参考图 1-3-2)。
图 1-3-2 表示物体运动的元素
实际在画面上显示物体时必须要有 x 坐标和 y 坐标,计算坐标就需要 vx 和 vy,而这两者则 可以通过物体的速度 v 与物体运动方向与 x 轴的夹角 θ 计算得到。
让物体沿 30 度角方向运动的程序
我们将上述计算程序化,制作一个让物体沿相对 x 轴 30 度角方向运动的示例程序,请参考 Movement_3_1.cpp(只是程序中的 y 坐标是基于电脑画面的,与数学中常用的坐标正好相反(向下为 +),所以请注意这里应该是向斜下方运动)。这个程序中决定物体运动的是 InitCharacter 函数中的以下 3 行代码(PI 为圆周率)。
代码清单 1-3-1 沿相对于 x 轴 30 度角的斜方向运动(Movement_3_1.cpp 片段)
fAngle = PI / 6.0f;
vx = PLAYER_VEL * cosf( fAngle );
// 设置初始速度
vy = PLAYER_VEL * sinf( fAngle );
程序中突然出现了正弦(sin)余弦(cos)的三角函数,为什么会用到它们呢?我们来详细 说明一下。首先可以明确的是 x、y 方向上的速度 vx、vy 与物体的实际速度 v 是成一定比例的。 也就是说,当运动方向的角度一定时,比如当 v 变为原来的 2 倍时,vx 和 vy 也应当变为 2 倍。把这种比例关系写成具体的算式,就可以得到
这里的 a、b 均为常数,称为比例系数。比如系数 a 代表 v 增加 1 时,vx 增加 a。大家可能会觉 得不好理解,这里举一个具体的例子。比如,当物体在水平方向运动时,
也就是说,此时的 a=1、b=0,并且物体的运动方向正好是 x 轴的方向,所以物体运动方向的 角度 θ 为 0。如果物体沿斜 45 度方向运动的话,速度正好会变为原来的
上式的计算过程可以参考 1.2 节的后半部分。即如果物体运动方向的角度 θ 变成 45 度,那就是 、 。也就是说,当 θ 为 0 时,a=1、b=0;当 θ 为 45 度时,、 。
那么当角度 θ 取任意值时,a 与 b 的值将会如何变化呢?为了得出结论,首先可以写出常数 a 与 b 必定满足的条件。由于 vx 和 vy 最终会合成速度 v,因此它们也满足勾股定理(参考图 1-3-3)。
图 1-3-3 对速度套用勾股定理
然后将vx = a · v、vy = b · v 的关系代入,得到
等式两边同时除以 v2,得到
很明显可以看出,上文中物体在水平、斜 45 度角方向运动时的 a、b,都满足上面的等式关系。
让我们再仔细看看这个等式,它与半径为 1 的圆(即单位圆)的公式是完全一样的。即将 a 作为横轴坐标、b 作为纵轴坐标所得到的点连接起来,正好可以画出一个半径为 1 的圆。由于 vx、vy 分别是 v 乘以 a、b 得到的,而 vx、vy 所决定的物体移动方向正好与 a、b 所做的单位圆上的点的方向相同,因此绘制出的角度 θ 将如图 1-3-4 所示。
图 1-3-4 单位圆与角度 θ
也就是说,a 与 b 分别代表单位圆上角度 θ 的点的 x 坐标与 y 坐标。根据三角函数的定义,a 与 b 可以表示成如下等式。
看到这两个等式,是不是会想问这是为什么呢?其实这就是三角函数中余弦函数、正弦函数的定义。即角度 θ 在单位圆上的位置的 x 坐标称为余弦,y 坐标称为正弦,这是由数学家所 定义的。从上式可以推导出
由此最终写出了示例程序中的语句。
只是示例程序 Movement_3_1.cpp 中仍然存在不明之处。
fAngle = PI / 6.0f;
即物体运动方向的角度被置为了 ,也就是本小节一开始所说的 30 度角方向。这是由于计算机中的三角函数(正弦余弦等)所能接受的角度,并不是以角度制为单位的(一周有 360 度)。30 度是一周的 ,那么程序中就必须以
这样的形式为单位来表示
周。稍加计算就能得到,的 12 倍,即 2π 可以表示一周。如果有人质疑是否真的是这样,可以将程序中的 fAngle 从
更改为 2π,即将运动的角度指定为 0,这样就可以看到物体会沿水平方向运动(画面右方向)。
事实上,这种将一周表示为 2π 的度量单位,称为弧度制。弧度代表半径为 1、圆心角为 θ 的圆弧,其弧长为 θ(参考图 1-3-5)。
图 1-3-5 弧度
因此完整一周的角就可以用半径为 1 的圆(单位圆)的周长来表示。圆周长计算公式为 2πr,一周的周长为 2π×1=2π ,也就是一周的角的弧度值。
那么,为什么计算机中的正弦余弦函数不使用看上去比较亲切易懂的角度,而用了看来不够直观的弧度呢?其实在近代数学中,三角函数中几乎都使用弧度,很少会使用角度。之所以使用弧度而不是角度作为角的度量单位,主要是关系到微积分的一些问题。如果坚持不使用弧度的话,三角函数中与微积分相关的一些公式几乎都无法使用,还可能会引发一些严重的问题。特别是游戏中会有与物理相关的公式,稍复杂的情况都会出现微积分的计算,因此读者朋友们在程序中涉及角的度量时,请务必使用弧度而不要使用角度。如果在一个程序中同时使用弧度和角度,就会非常容易出现 BUG 及各种问题,因此将角的度量单位无条件统一为弧度这一原则,应当被无条件地贯彻下去。
POINT 请将角的单位统一为弧度。
使用弧度的程序
既然提到了弧度的话题,就来写个小程序实践一下吧。Movement_3_1.cpp 中物体每次出现都会向同一方向运动,这里做一个小修改让物体每次出现时的运动方向都不同。为此对 MoveCharacter 做如下改动,如代码清单 1-3-2 所示(Movement_3_1a.cpp 片段)。
代码清单 1-3-2 使用弧度让物体的运动方向每次都不同(Movement_3_1a.cpp 片段)
| int MoveCharacter( void )
// 每帧调用一次
// 实际运动
// 运动到画面外时回到初始位置
if ( ( x & -CHAR_WIDTH )
|| ( x & VIEW_WIDTH ) ||
( y & -CHAR_HEIGHT ) || ( y & VIEW_HEIGHT ) )
x = ( float )( VIEW_WIDTH - CHAR_WIDTH ) / 2.0f;
y = ( float )( VIEW_HEIGHT - CHAR_HEIGHT ) / 2.0f;
fAngle += 2.0f * PI / 10.0f;
// 增加角的弧度
if ( fAngle & ( 2.0f * PI ) ) fAngle -= 2.0f * PI;
// 经过一周后弧度重置
vx = PLAYER_VEL * cosf( fAngle );
vy = PLAYER_VEL * sinf( fAngle );
这个程序的关键点在于物体移出画面外时的处理。
fAngle += 2.0f * PI / 10.0f;
// 增加角的弧度
if ( fAngle & ( 2.0f * PI ) ) fAngle -= 2.0f * PI;
// 经过一周后弧度重置
第一行的作用是,当物体移出画面时使运动的角增加
周。因此物体出现 10 次后,运动方向正好经过了一周。
这里将每次角变化的量写为了 2.0f * PI / 10.0f。数学好的人肯定会说,与其写成 2.0f * PI / 10.0f,直接写成 PI / 5.0f 不是更简单吗?如果从普通的数学题的角度来说,这确实可以简化为 PI / 5.0f。但是这里其实是有意而为之的,因为这样的写法可以一目了然地知道每次角增加的量是一周的几分之一(换句话说就是经过几次可以转一周)。书写为 2.0f * PI / 10.0f 时,不用特别计算也能知道 2π 为一周,将其 10 等分后,10 次即满一周;而如果是 PI / 5.0f,就需要去心算,一周的弧度是 2π,2π 除以 PI / 5.0f 是多少等等,一点也不直观,理解起来也容易产生问题。程序代码不光是为了计算机,更要考虑读代码的人,因此这里特意将参数写成了 2.0f * PI / 10.0f。
如果再多考虑一些,写成 (2.0f * PI) / 10.0f 的话是不是更友好呢?众所周知,括号是可以改变运算顺序的,比如 1.0f + 2.0f * 3.0f 与 (1.0f + 2.0f) * 3.0f 的结果是不一样的。然而 (2.0f * PI) / 10.0f 与 2.0f * PI / 10.0f 的计算结果并没有什么不同(这里暂不考虑运算精度),因为 (2.0f * PI) / 10.0f 与 2.0f * PI / 10.0f 的运算顺序本来就是一样的,对于计算机来说,怎么书写都没有什么影响。但是如果写成 (2.0f * PI) / 10.0f,2.0f * PI 就被人为地划分为了一个整体,代表一周的弧度,对于人类来说则更加容易理解,因此虽然只是多了一对括号,但却让程序更加自然易懂了。
POINT 编程时不仅要考虑到计算机,还要考虑到读代码的人。
让我们再回到代码。请大家看一下程序的关键部分的第 2 行。
if ( fAngle & ( 2.0f * PI ) ) fAngle -= 2.0f * PI;
// 经过一周后弧度重置
这一句有什么作用呢?如注释所示,这行语句会在弧度增加到比一周大时,减去一周的弧度。其实弧度增加或减少一周,等于旋转了一周后又回到了原位,其代表的夹角方向并没有改变,从数学角度来说,这一行代码是没有任何意义的。事实上即使将这行删除,至少在这个程序中,也会得到相同的运行结果。
那么这行代码真的只是摆设吗?并不是这样的。这行的意义在于控制角的弧度始终在 0 ≤ θ ≤ 2r 的范围内,不让角 θ 无限制地增大下去。确实从数学角度来说,角 θ 无论多大(即弧 度增加若干周)都没有问题。比如 2π、4π、6π ……全部表示同样的角,2.5π、4.5π、6.5π…… 全部等同于 (90 度)的角。数学中一个角无论多大都不会有问题,而实际上计算机用一个极大值计算正弦余弦时却可能出问题。
因为角的弧度值在计算机内部是以浮点数的形式表示的,即形如 1.2×102 这种指数形式的数字。浮点数只用很少的位数(= 很少的内存容量),就可以表示 100 000 这样的大数。与此相对,浮点数存在计算绝对值较大的数时会丢失精度的问题。比如计算 10 与计算 100 000,计算误差会相差一万倍。因此计算机在计算含有小数点的数字时,会受到绝对值较大的数的影响丢失精度。如果轻视上面的问题,程序在循环中一次次增加弧度时,随着循环次数的增多,弧度增大到某个值之后精度就会开始丢失,最终程序运行的结果可能会与预期完全不一样。因此除非有特殊需要,我们在编程时都要注意避免产生一个极大的弧度值。此外,计算机的三角函数在处理一个很大的弧度值时,其运算时间也可能会加长,这里就不再具体论证了。总之我们要尽可能地控制 θ 的取值范围在 0 ≤ θ ≤ 2π(或者 -π ≤ θ ≤ π)之内。
POINT 计算机计算不可避免的会有误差,绝对值越大误差也越大。因此应该尽可能地使用绝对值小的数字进行计算。
1.4 在物体运动中加入重力
 抛物运动、重力加速度、计算误差、积分       前半部分  后半部分
在现实世界中,物体会受到重力向下的作用力。本节就让我们来学习如何将重力应用到游戏世界中,让运动表现得更加真实吧。
本小节中,我们将讲解如何通过给物体施加重力来更加真实地表现物体的弹跳等运动。物体在重力作用下的运动称为抛物运动,Movement_4_1.cpp 为将抛物运动简单地用编程语言编写出来的示例程序。
图 1-4-1 抛物运动的程序
其中最关键的是 MoveCharacter 中的以下 2 行。
// 对位置加入速度
// 为速度加上加速度
其中 vy 是物体在 y 方向的速度,GR 为重力加速度。所谓重力加速度,即做抛物运动的物体被施加的加速度。为了进行下面的加速度相关的话题,首先来确认一下速度与加速度的关系。速度可以表达为
所以移动距离其实就是位置的变化量,可将其写成 Δx,希腊字母 Δ 代表其标记的值为变化量。同理,时间也等同于时刻的变化量,将其书写为 Δt。那么速度 v 就可以表达为
这是位置与速度的关系式,其实这个关系式也适用于速度和加速度。假设加速度为 a,速度的变化量为 Δv,则有
由上面的式子可以知道,每单位时间位置的变化量是速度(根据式①),每单位时间速度的变化量是加速度(根据式②)。那么将上面的等式再做一下变形,就可以得到
时间 Δt 的单位为帧,在一般的游戏中,1 次循环经过的时间为 1 帧,即 Δt=1。代入上式得到
而由于 Δx 是 x 的变化量,假设当前的 x 为 xn,上一个 x 为 xn-1,则有
Δx = xn - xn-1
同理,Δv 是 v 的变化量,假设当前的 v 为 vn,上一个 v 为 vn-1,同样有
Δv = vn -vn-1
那么式③就可以表示为
然后将 xn-1与 vn-1 移项到右边,得到
将上式中的 x 置为 y,a 置为 GR,就得到了程序中的语句。只是单凭上面的讲解理解起来可能有点难,请仔细观察程序的运行以帮助理解。
自然界中的物体都会受到重力作用而产生向下的加速度,这个加速度的值是固定的,程序中的常数 GR 就表示该加速度。GR 的值在每帧都会被加到 vy 变量中,所以 y 方向的速度 vy 会逐帧增加,像 0、GR、2GR、3GR……这样,每帧都会增加 GR,同时每次增加的速度还会被加到位置 y 中去,这样最终程序运行的结果就是带有加速度的抛物运动了。
需要注意的是这里我们用到的重力加速度 GR,并不是自然界中平时使用的值 9.8。众所周知的重力加速度值 9.8 是真实的地球上的重力加速度,以 m/s 2(米 / 平方秒)为单位,并不能在 计算机的虚拟空间中适用。计算机所使用的重力加速度单位是特殊的 dot/F2(像素 / 平方帧),请注意区别。
抛物运动在斜抛中的应用
接下来,让我们将抛物运动从平抛扩展到斜抛。为此将 InitCharacter 函数中的
// y方向的初始位置
vy = 0.0f;
// y方向的初始速度
更改为(Movement_4_1a.cpp)
y = 200.0f;
// y方向的初始位置
vy = -10.0f;
// y方向的初始速度
物体被向上抛出后,在重力作用下会改变运动方向,变为向下掉落。这与游戏中角色进行跳跃的动作是一致的,可以类推到很多场景中。
那么到此是不是就可以结束了呢?很遗憾还不能。我们到目前为止得出的结论,对于那些物体运动不是至关重要的程序来说是足够用的,比如业余时间制作的游戏中角色的动作,或者大量粒子飞溅的效果(particle)等。但如果是用于商业游戏,特别是动作比重非常大,如动作游戏中角色的运动,上面的结论是有缺陷的。
写了这么半天,结果却说得出的结论有缺陷,可能会有读者感到不满吧。其实所谓的缺陷,并不是说我们之前的结论是错误的,只是告诉读者上面的结论中存在不够准确的部分。如果用更接近数学上的语言来说,就是在之前的一些算式中,只是粗糙地使用了近似值,经过很长时间之后计算结果可能会变得不准确。具体来说,比如
这一部分。这个算式小学生应该都学过的吧,那么有些思维严谨的孩子会不会多想一些问题呢?比如:分母为时间,但如果加速的话,这段时间内的速度也会改变,这样一来上面的等式不是就有问题了吗?是的,有这样的疑问一点也不奇怪。因为上面的等式中要计算的速度,本来就会根据计算时所取的时间的不同而变化。而这个“速度 = 距离 / 时间”的等式,也只有在速度恒定时才成立,有加速度时就不正确了。有加速度时,就必须考虑到在 Δt 这段有限的时间内,速度也是不断变化的(参考图 1-4-2)。
图 1-4-2 匀速与加速时经过相同时间后产生的差异
在有加速度时,无论取 Δt 时间前的速度,还是取 Δt 时间后的速度,用上面的算式计算出的某时刻的位置都是不正确的,因为速度是在不断变化的。
那么在有加速度时怎样正确地计算位置呢?我们需要更改一下思考方式。设想有无限小的时间,然后将所有在无限小的时间内行进的距离(距离也是无限小的)都加起来就可以了。也就是说,我们最终会将无数个无限小的数加起来(听起来有些像哲学上的问答)。当然计算机是无法处理无限小的数的,也不可能真的去将无数个数相加。所以在实际的程序中,都是人类使用某些算法计算无限小的数字后,直接将计算结果添加到程序中的。
可能很多读者已经明白了,这个所谓的将无数个无限小的数字相加的操作,在数学上称为积分。是的,就是有名的微积分中的积分。听到微积分,可能不少人都会多少有些抵触情绪吧。其实包括我自己都不太想去碰及,但是为了达到目的,也只好硬着头皮上了。因为积分正好可以解决被施加了重力的状态下的问题。不过好在这里并不会涉及微积分比较深入的部分,如果想比较深入地学习微积分,少则也要花两三年时间吧。我们的目的不是要成为数学家,而是要制作游戏,所以只要囫囵吞枣地学会使用伟大先哲们总结出的公式就可以了。
假设位置为 x、速度为 v、加速度为 a、经过时间为 t,将会有以下关系式成立。
这里出现的 ,是函数 f(x) 关于变量 x 的不定积分。对这部分知识不够了解的读者,可以参考第 6 章。
让我们回到刚才的话题,在已经论证过的等式
Δx = v Δt
中加入无限小的概念。由于上例中 y 方向上被施加了 GR 的重力加速度,因此将 GR 表示为 G,则有
由后一个式子可以得到
C1 称作积分常数,代表 t=0 这一时刻的速度,也就相当于物体的初始速度。而由前一个式子 =可以得到
C2 这个积分常数,是 t=0 时的 y 坐标,即物体的初始位置。我们最终得到的这个式子,可能看过大学数学课本的人都会有点印象,其实这就是使用积分所推导出的结果。
那么与使用积分的正确方法相比,我们一开始采用的通过循环在位置中叠加速度,并在速度中叠加加速度的方法,究竟有多大的偏差呢?简单起见,我们将初始速度和初始位置都置为 0,那么在使用积分的方法中的等式
中,就相当于 C1 = 0、C2 = 0,得到
而在循环中每次叠加一个数字,这在数学上叫作级数。比如 t 时刻的速度 vy 可以写成
到目前为止,两种方法计算速度的结果还是一样的。但是很遗憾,之后计算位置 y 时结果就不正确了。
在上式中,之所以将 Gt 表示为了 G(i-1),是因为 y 在加了 vy 之后,由于需要给 vy 加上加速度,计算 y 时所使用的 vy 的更新被延后了 1 次。于是有
相比使用积分时正确的计算结果,
结果中少了
。也就是说,对位置叠加速度,再对速度叠加加速度这一简单算法,在程序中随着时间的推进,误差会变得越来越大。如果真正的游戏中出现了这种误差,游戏的玩家很可能会认为游戏有问题,甚至会听到这样的抱怨:“这个角色的动作有问题啊,移动起来总是差了 3 个像素,真是个烂游戏!”
上面的例子可能有点吹毛求疵,但是如果忽视这小小的误差真的会造成很多问题。比如网球、高尔夫球、棒球等球类比赛中球的运动总是至关重要的,球的旋转以及空气摩擦的影响等因素都必须考虑在内,如果有误差就可能会影响球的运动轨迹。在动作游戏中,误差会引起帧速率不固定,可能会使运算结果无法重现,例如因运算时机受到误差影响而丢掉一帧的运算。此外有多台机器同时开发一个游戏时,如果不注意误差,就会出现配置好的机器和配置差的机器的帧速率不一致的情况。而计算机的运算速度也不是恒定的,必须保证运算时间有变化时物体运动的速度也不受影响。如果帧速率无法保证,虽然每一帧的计算误差极小,但最终表现出的差异也会很惊人,有时候甚至会让物体彻底偏离运动轨道,特别是在动作游戏中,一点微小的动作偏差都会影响游戏性,最终演变成大问题。因此为了保证计算的精度,希望大家尽可能地在程序中使用积分进行运算。
使用积分制作的抛物运动程序
使用积分实现的抛物运动程序,请参考示例程序中的 Movement_4_1b.cpp。在该程序的 MoveCharacter 函数中,
// 决定x方向的位置
y = 0.5f * GR * t * t + vy * t + 200.0f;
// 决定y方向的位置
这一部分是应用积分计算的结果求 x 方向、y 方向的位置。如果用通常的算式写出来就是
这个程序的特点是,只需要知道物体运动过程中的具体时刻,就可以直接算出物体的位置。这样处理能够使误差不会随着时间慢慢增大,所以即使帧速率不固定,物体也可以沿同一轨道行进。不过使用积分算法的程序 Movement_4_1b.cpp,与使用叠加算法的程序 Movement_4_1a.cpp 相比,用肉眼应该无法看出什么区别,可以实际运行两个程序对比一下。
那么是不是使用上面的积分来精确计算轨道就万无一失了呢?很遗憾这是不可能的。如果地球上只有重力一种力的话还没什么问题,但是物体运动往往会受到多个复杂的力的共同作用,想精确求得物体每一个瞬间的轨道几乎是不可能的,这种情况下只能根据数据计算物体近似的运动轨道。
1.5 物体随机飞溅运动
 随机数、均匀随机数、正态分布       前半部分
  后半部分
本小节将实现大量物体随机飞溅的效果。随机飞溅虽然是常见的现象,但实现起来却没那么容易哦。
本小节中,让我们来学习大量物体以随机初速度飞溅的运动。比如火山喷发、烟花、摩擦迸出的火花等状况都可以套用这种运动。由于物体飞溅运动其实也是在进行抛物运动,所以建议大家掌握 1.4 节中抛物运动的程序后,再来阅读本小节。
图 1-5-1 物体随机飞溅运动的程序
将物体随机飞溅运动程序化之后的示例程序为 Movement_5_1.cpp。这个程序的重点是如何产生随机的初速度,具体是以下 4 行(代码清单 1-5-1)。
代码清单 1-5-1 产生随机速度的部分(Movement_5_1.cpp 片段)
Balls[i].vx = rand() * VEL_WIDTH / ( float )RAND_MAX
- VEL_WIDTH / 2.0f;
// 随机设置vx的初始值
Balls[i].vy = rand() * VEL_HEIGHT / ( float )RAND_MAX
- VEL_HEIGHT / 2.0f - BASE_VEL;
// 随机设置vy的初始值
为了理解这两行代码,首先就需要理解如何产生一个从 0 到 n 范围内的随机数。在这个程序中,为了获得 0~n 的随机数,使用了以下算法。
rand() * n / ( float )RAND_MAX
为什么这样就可以得到 0~n 的随机数呢?这需要先明白 RAND_MAX 常数代表什么。读过一遍示例程序就可以发现,RAND_MAX 常数在这个程序中并没有定义,而是事先在 C 语言等的头文件中定义好的,代表 rand 函数所能返回的最大值。所以 rand() / ( float )RAND_MAX 这个语句就可以得到 0 到 1 之间的随机数。而 rand() * n / ( float )RAND_MAX 是将 0 到 1 之间的随机数再乘以 n,因此结果就可以得到从 0 到 n 的随机数。
既然 rand() / ( float )RAND_MAX 得到的是从 0 到 1 的值,那么将获得从 0 到 n 的随机数的程序写成
(rand() / ( float )RAND_MAX ) * n
不是更容易理解吗?这里之所以写成 rand() * n / ( float )RAND_MAX,是因为考虑到如果 n 是整型,就可以首先计算 ( rand() * n) 的整型乘法,这样在速度上会更有保证。如果不小心将上面的语句更改为
(rand() / RAND_MAX ) * n
(rand() / RAND_MAX) 部分进行计算时小数点以后的部分就会被全部舍弃,导致大部分结果都是 0,程序也就失去原有的意义了。
可能有编程经验的读者会想到使用求余的方式。确实,获得 0~n 的随机数时,用求余运算是比较普遍的方式,如下所示。
rand() % ( n + 1 )
其中 % 代表求余运算符,上面的语句的效果是,无论 rand 函数生成的随机数的范围是多少,只要将其产生的随机数除以 (n+1),就可以得到 0~n 的随机数。比如用上述方法生成 0~3 的随机数时,rand 的返回值如表 1-5-1 所示。
表 1-5-1 rand 函数的返回值以及最终结果
A012345678
B012301230
A: rand 函数的返回值 B: rand() % (3 + 1) 的值
使用这个方法确实可以将 rand 函数的返回值归纳为 0~n 的随机数。那么为什么我们没有采用这种方式去编写之前的示例程序呢?这是有若干原因的。其中最重要的原因是,采用求余的方式获得的随机数必然只能是整数。因为所谓“求余”,就是在整数范围内取得一个余数。然而在示例程序中,如果将速度的 x 分量和 y 分量都设置为整数,向同一方向飞溅的物体就会骤然增多,导致效果显得很不自然。对此有兴趣的读者可以将上面的 4 行代码做如下变更(Movement_5_1a.cpp)。
Balls[i].vx = ( rand() % ( VEL_WIDTH + 1 ) )
- VEL_WIDTH / 2.0f;
Balls[i].vy = ( rand() % ( VEL_HEIGHT + 1 ) )
- VEL_HEIGHT / 2.0f - BASE_VEL;
虽然这种写法很简单,但通过运算结果就可以发现,向正上方飞溅的物体增多了,具有同样速度的物体也增多了,看起来多多少少显得不够自然。
再来看示例程序 Movement_5_1.cpp 中计算 vx 的地方。
rand() * VEL_WIDTH / ( float )RAND_MAX
- VEL_WIDTH / 2.0f;
// 随机设置vx的初始值
程序会首先产生从 0 到 VEL_WIDTH 的随机数,然后减去 VEL_WIDTH / 2.0f,最终就产生了 -VEL_WIDTH / 2.0f 到 VEL_WIDTH / 2.0f 范围内的随机数。由于一般物体随机向上飞溅时,我们都希望向左方向与向右方向产生的飞溅效果是对称的,因此这里使正负值以相同的概率产生。同理,对 vy 的计算也采用同样的方式,
rand() * VEL_HEIGHT / ( float )RAND_MAX
- VEL_HEIGHT / 2.0f - BASE_VEL;
// 随机设置vy的初始值
之所以这样设置,是因为在火山喷发等场景中,通过设置一个平均速度为基础速度,然后使所有的物体飞溅都在基础速度上再随机加上一个加速度 a,从而就可以让整体效果显得更加真实。此时产生的随机数范围是 (-BASE_VEL - VEL_HEIGHT / 2.0f ) 到 (-BASE_VEL + VEL_HEIGHT / 2.0f )。
按照上面的思路,不难想象我们可能会经常用到一个功能,即产生从 a 到 b 的随机数。为此,我们可以把这个功能提炼成函数,通过此函数来产生指定范围内的随机数。此时随机数的计算公式如下所示。
rand() * ( b - a ) / ( float )RAND_MAX + a
请读者自行确认之前的计算 vx 与 vy 的式子是否满足该公式。
为什么会感到不自然
上面我们制作了一个限制随机数范围,让物体随机向上飞溅的程序,但是有些人可能会感觉其运行结果有些不自然。其实这个程序中的随机数的产生方式,与自然界中真正的物体飞溅现象相比,是有很大不同的。当然,自然界中物体飞溅的方向是 3D 的,这是其中一个原因,然而更重要的原因是,自然界中这种情况下的初速度分布并不是均匀随机数,而是满足正态分布的。下面就依次说明什么是均匀随机数和正态分布。
首先说明均匀随机数。所谓均匀随机数,是指所有数字的出现概率都相等的随机数,也就是之前程序使用 rand 函数所产生的随机数 1。使用均匀随机数所造成的现象就是物体向正上方的概率与其他方向的概率都是相同的。
而所谓正态分布,是指随机数中有一个值取得的概率最大,其他值取得的概率比较小,具体来说,正态分布可以表示为
其中 μ 代表均数,即最高概率出现的点,σ 则称为标准差,表示概率分散的程度(参考图 1-5-2)。
图 1-5-2 正态分布的图像
以自然界中的火山喷发为例,火山岩会以最高的概率向某个特定方向喷溅(一般是正上方),向其他方向喷溅的概率较低。而在 Movement_5_1.cpp 中,由于我们没有限制速度的取值范围,速度在所有方向上的取值都是等概率的,因此看上去就会觉得不太自然。
那么如果我们从自然界中学习,让速度满足正态分布,物体的运动会不会变得自然一些呢?答案当然是肯定的。但是想要在程序中实现均数为 μ、标准差为 σ 的正态分布却不是一件容易的事。C 语言中并没有提供一个方便的函数可以直接生成满足正态分布的随机数,我们只能基于产生均匀随机数的 rand 函数,通过自己的计算来实现。但是如果要自己琢磨计算方法恐怕很有难度,因此我们需要仰仗一下先贤的智慧。
Box-Muller 算法是一种能根据均匀分布的随机数来产生正态分布的随机数的算法。根据 Box-Muller 算法,假设 a、b 是两个服从均匀分布并且取值范围为从 0 到 1 的随机数,我们就 可以通过下面的公式获得两个满足正态分布(均数为 0,标准差为 1)的随机数 Z1 和 Z2。
等式中的 ln(x) 代表自然对数函数,即以 e (=2.71828...) 为底的对数函数。上式对应的图像是在半径为
的圆上取随机角度的点,点的 x 坐标为 Z1,y 坐标为 Z2。为什么这样的 Z1 和 Z2 会满足正态分布呢?详细说明起来会比较复杂,也超出了本书的范围,因此在此略过。总之大家只要将 Z1 和 Z1 作为两个没有任何相关性的随机数去使用就可以了。
将上式实现为程序,即示例程序 Movement_5_2.cpp。这个程序首先会生成均数为 0、标准差为 1 的满足正态分布的随机数,并通过下面的方式限制随机数的范围。
代码清单 1-5-2 限制利用正态分布生成的随机数的范围
| Balls[i].vx = ( fRand_r * cosf( fRand_t ) ) * VEL_WIDTH;
// 随机设置vx的初始值
| Balls[i].vy = ( fRand_r * sinf( fRand_t ) ) * VEL_HEIGHT - BASE_VEL;
// 随机设置vy的初始值
与之前程序中使用的均匀随机数不同的是,在这段程序中,例如 vx 的取值范围没有被限制在 -VEL_WIDTH / 2.0f 到 VEL_WIDTH / 2.0f 之间,即速度超过此范围的物体也会偶尔出现。这样,我们最终就实现了自然界中的随机现象——正态分布。
1严格讲,rand 函数所产生的随机数只能称为“伪随机数”,并不一定是真正意义上的均匀随机数,只是经过将 0~RAND_MAX 范围内的值转换为 a~b 范围内的值这一处理后,虽然结果可能会和均匀随机数有所偏差,但由于对程序的影响较小,因此直接忽略了。
1.6 让物体进行圆周运动
 角速度、向心力       前半部分  后半部分
在本小节,我们将实现使物体以一点为中心进行旋转的圆周运动。
下面就让我们来学习如何让物体围绕一个中心点做圆周运动吧。
图 1-6-1 物体围绕中心点旋转的程序
将运动简单程序化后的示例程序为 Movement_6_1.cpp。这个程序中,重点部分是 MoveCharacter 中的以下 3 行。
代码清单 1-6-1 使物体围绕中心点旋转的程序的主要部分
x = ROT_R * cosf( fAngle ) + ( VIEW_WIDTH - CHAR_WIDTH ) / 2.0f;
y = ROT_R * sinf( fAngle ) + ( VIEW_HEIGHT - CHAR_HEIGHT ) / 2.0f;
fAngle += 2.0f * PI / 120.0f;
// 增大角度
这里并没有考虑物体运动中的速度和加速度的情况(如重力作用),而是直接计算了物体的位置。具体来说,是基于以下原理计算的。首先根据三角函数的正弦余弦定义有
在一个以原点为中心的单位圆 ( 半径为 1 的圆 ) 上,根据角度 θ 就可以表示一个点的位置(参考图 1-6-2)。
图 1-6-2 三角函数的定义
如果上式中 θ 的值随时间递增,就会形成以原点为中心半径为 1 的圆周运动(参考图 1-6-3)。
图 1-6-3 伴随时间进行的圆周运动
将这个圆的半径乘以 r,并将原点设置为 (x0,y0),那么围绕着 (x0,y0) 的半径为 r 的圆周运动就为
于是就有了程序中的以下两行。
x = ROT_R * cosf( fAngle ) + ( VIEW_WIDTH - CHAR_WIDTH ) / 2.0f;
y = ROT_R * sinf( fAngle ) + ( VIEW_HEIGHT - CHAR_HEIGHT ) / 2.0f;
fAngle += 2.0f * PI / 120.0f;
// 增大角度
这一行决定了物体的旋转速度。在物理学中表示物体的旋转速度时常使用角速度。角速度一般写作 ω,可以表示为 θ=ωt。那么角速度就满足等式 ,即单位时间内角度的变化量。
使用角速度 ω 计算虽然方便,但是究竟多长时间才能旋转一周呢?我们无法通过角速度直观地得出结论。为此就有了周期 T 的概念, 即
。显然,经过时间 T 后,物体正好旋转一周(2π=单位圆的圆周长,正好是 T 时间所经过的角度)。因此示例程序中每次为角度 fAngle 增加
,就用 120 帧的时间(2 秒)完成了一周圆周运动。
用上面的方法,可以在不考虑加速度的情况下,很简单地算出物体的 x 坐标与 y 坐标。而如果是速度中包含了加速度的圆周运动应当如何计算呢?比如系统中需要同时处理重力与空气阻力,或者处理不完整的圆周运动(例如游戏中角色使用绳索从一处荡到另一处)等。在这些情况下,如果还只是简单地对位置累加速度,对速度累加加速度,其结果肯定不是圆周运动。此时为了让物体仍然呈现圆周运动,我们要如何处理加速度呢?
这里需要先参考一下 1.4 节后半部分中的这两个等式
这是当施加的加速度确定时,求速度与位置的计算公式。圆周运动则正好相反,是需要根据确定的位置来算出加速度,所以只要把上面的等式逆运算就可以了。而积分的逆运算,就是大家都知道的微分。那么根据位置计算速度,根据速度计算加速度,就有以下等式成立。
使用这两个等式,就可以计算出圆周运动的加速度。首先仅考虑 x 方向,假设物体正在以原点为中心、以 r 为半径、以 ω 为角速度进行圆周运动,此时 x 方向旋转满足等式
x = r · cos (ωt )
然后计算 x 方向的速度 vx,
根据 vx,就可以计算出 x 方向的加速度 ax 。
上面的式子与原来的 x 坐标的计算公式
x = r · cos(ωt )
相比,由于两边都含有 r 与 cos(ωt ),因此可以得到下面的关系等式。
ax = -ω2x
可以看出,为了以原点为中心做圆周运动,只需将当前的 x 坐标乘以 -ω2 的结果作为 x 方 向的加速度即可。请注意 cos 及 sin 等三角函数在计算中已经被消去了。
接下来用同样的方式计算 y 坐标,有
同样可以看到,三角函数都被消去了。整理一下 ax 、ay 的结果,有
从向量的角度来重新审视一下上面的等式,可以得出这样一个结论,即将物体所在位置的位置向量乘以 -ω2 作为加速度,就会形成以原点为中心的圆周运动(参考图 1-6-4)。
图 1-6-4 位置向量在加速度作用下进行圆周运动
等式中的负号,表示被施加的加速度(或力)是始终指向原点即旋转的中心方向的。于是我们就可以得出结论,即对物体施加一个指向某一点的角速度为 ω 的力,物体就会围绕该点做圆周运动。由于这个力始终指向旋转的中心,因此称为向心力。使用向心力实际制作的圆周运动程序请参考示例程序 Movement_6_2.cpp。
请注意在这个程序中,InitCharacter 函数里决定物体的初始位置及初始速度的是以下部分。
代码清单 1-6-2 使用向心力的圆周运动中决定初始位置及初始速度的部分
rx = ROT_R;
// 初始位置
ry = 0.0f;
vx = 0.0f;
// 初始速度
vy = ROT_R * ANGLE_VEL;
可以看到初始位置为 (ROT_R, 0.0f),即 (r, 0) 的位 置,初始速度为 (0.0f, ROT_R * ANGLE_VEL),即 (0, rω)。为什么要这样设置呢?这是因为如果想要围绕某个初始位置做圆 周运动,首先必须将物体放在圆周的某个点上才行。于是将 t=0 代入刚才的公式
得到的 (r, 0) 就是初始位置。其次,只要将 t=0 代入对 x、y 进行微分得到的关系式
就可以得到初始速度 (0, rω)。
由于上面的算式中已经将三角函数全部消去了,因此这个程序中控制物体实际运动的 MoveCharacter 函数中并没有使用 sin、cos 等三角函数进行运算,只是简单地使用了加法乘法等,而最终效果与使用了三角函数的圆周运动是完全一样的。实际上 sin、cos 等运算比简单的四则运算要耗时很多,因此使用向心力实现的圆周运动,比使用三角函数实现的圆周运动在运算速度方面更胜一筹。
综上所述,我们掌握了两种在游戏中实现圆周运动的方法,一种基于三角函数,另一种则使用了向心力,大家可以根据具体情况选择使用恰当的方法。
1.7 [ 进阶 ] 微分方程式及其数值解法
 微分方程、数值解法、欧拉法      
在使用计算机表现物体运动时,运动是否正确是受条件限制的。在本小节,我们就来一起了解下这种限制存在的原因吧。
本章围绕着物体运动这一话题,编写了不少表现物体运动的程序实例。然而计算机终究只是一种在有限的时间内进行计算的机器,它所能正确表现的物体运动自然是有限的。那么为什么会有这些制约条件呢?本小节就会对此进行说明。首先从基本原理开始,当一个物体被施加力时,该物体就会产生与所施加的力成比例的加速度。用公式表达就是
F 代表物体被施加的力,m 是物体的质量,a 是物体产生的加速度。这就是有名的运动方程(牛顿第二定律)。从这个公式中可以得到
而我们在 1.6 节中已经介绍过,位置、速度、加速度之间有以下关系。
代表将位置以时间进行 2 次微分。将其代入之前的运动方程式,可以得到
这种等式中含有微分的方程称为微分方程。如果能将这个描述物体运动的微分方程求解,理论上来说就可以重现任何力作用下的物体运动。
然而要将这个看似简单的算式求解,也不是那么容易的。首先让我们来考虑最简单的情况。在这种情况下,只在微分方程两边对 t 进行 2 次积分就可以求解,比如 1.4 节中只对物体施加重力就属于这种情况。下面我们就来实际操作一下,给物体施加重力加速度 g,即 mg 的力,这时有
然后在等式两边对 t 进行积分,由于微分是积分的逆运算,所以左边的微分被消去一次,得到
上式中,C0 是左右两边的积分常数的和。对上式再次进行积分,得到
C1 是两边的积分常数。这个式子与 1.4 节中我们推导出的等式一致。
但是在现实中几乎没有人会像这样对运动方程两边进行积分求解。例如,根据胡克定律,具有弹性的物体会被施加一个名为 -kx 的力。k 称为劲度系数,弹簧越硬劲度系数越大。此时上式为
这个式子就无法简单地对两边进行积分求解 x。因为积分求解的对象是 x,而 x 在等式右边也出现了,即使对两边进行积分,也仅仅是将等式从微分变成了积分,没有什么实际意义。下面来实际操作一下。
求得的结果中仍然包含 x,无法解决问题。针对上式,最简单的解决方式是换个角度,既然求解 2 次微分的目的是消去未知数的微分或积分符号,那么如果有一个函数通过 2 次积分后能保持不变就可以解决这个问题了。比如 sin 函数就有这样的特性,sin 函数进行 1 次微分后会变成 cos 函数,再次进行微分又会变回 sin 函数。那么就让我们按这个思路来试试这个方法是否可行吧。首先,假设
这里的 A 与 ω 都是常数。然后对等式进行微分。顺便提一下,下面的求解过程与 1.6 节中对圆周运动的求解过程几乎是一样的。
对比一下具有弹力的物体的运动方程
可以看到,如果 ,则两个方程是一致的,所以 x = A · sin(ωt )是满足此运动方程的。即 ,此时方程的解为
如果对这个答案有怀疑,可以将解代入运动方程实际验证一下是否真的成立。不过比起之前求解用的普通方程,微分方程的求解过程总是让人感觉不够工整,就连最终求出的解也像是东拼西凑出来的。
上面推导出了满足带弹簧的物体的运动方程的解,但是需要注意的是这个运动方程并不是只有这一个解。我们所求出的
这个等式只是满足该运动方程的一个解,并不能否定其他解存在的可能性。实际上也可能会得到其他形式的解,本书中不再深入谈论。
上文中我们介绍了运动方程的两种解法:对等式两边积分以及利用三角函数。那么所有的运动方程是不是都可以通过这两种方法求解呢?很遗憾这是做不到的。微分方程可以分为线性微分方程与非线性微分方程两种。上面的例子中,重力的等式与弹簧的等式都是线性微分方程。数学家研究得出的结论是,线性微分方程是必然有解的,而非线性微分方程会存在一些无论如何都无法求解的情况。
举一个游戏开发者身边的例子,这个例子涉及流体力学的情况。比如挥动一面旗帜,这时的运动就不能通过简单的函数来表示。在这些情况下,我们就需要使用微分方程的数值解法,即牺牲了一定程度的正确性的数值计算。实际上 1.4 节中最开始的程序以及本章的很多问题都已经在使用这种方法了,只不过之前的所有算法,都仅仅采用了数值解法中最简单的、误差最大的算法。具体来说,就是在欧拉法中将 Δt 置为 1 的解法。下面将进一步对欧拉法进行说明。
所谓欧拉法,就是通过逐步计算来求得微分方程的近似解。这里的“逐步”具体来说有点类似程序中的循环,根据前一次的计算结果来进行下一次计算。在求解微分方程时,如果想要得到最精确的结果,采用欧拉法只能永无止境地计算下去,因为每次计算都只能得到当前的结果。因此对于复杂的非线性微分方程,想要精确求解本来就是不可能的,我们只能努力求得某个可以接受的精度的解。现实中使用欧拉法时,虽然可以进行多次逐步求解,但往往只会求第一次近似这种最简单的近似解,也就是将微分方程近似为差分方程来求解。具体来说就是以下的近似过程。
首先,位置 x 与速度 v 之间本来就有
这样的关系。这个等式的左边是微分,如果想要通过速度计算位置,原本是必须进行积分的。但是在欧拉法中可以近似为
这里的 Δt 是时间间隔(在游戏中就是 1 帧 =
秒),Δx 是在 Δt 时间内位置 x 的变化量。也就是说,Δx 其实就是现在的位置减去前一次的位置。假设将现在的位置表示为 xn,前一次的位置表示为 xn-1,上面的等式就可以表示为
同理,速度与加速度的关系为
将两个式子并列起来得到联合方程组
这就是欧拉法的等式。并且这里第一个等式中的 v 可以直接使用第二个等式中的 vn 或 vn-1。
在上面使用过的近似等式
中,当 Δt 取无限小时,左边就直接转化为了微分,也就不是近似了。用等式可以书写为
也就是说,欧拉法的等式
在使用第一次近似时可能会采用较粗略的计算,但其实如果 Δt 足够小的话,其计算精度也会上升。而在本章的 1.4 节中也提到过,游戏中经常会把欧拉法的等式中的 Δt 设置为 1,即
这样计算所使用的时间间隔就变为了 1 帧,即 1/60 秒。在物体被施加了加速度的情况下,这样计算所产生的误差是无法被忽视的。即便如此,很多游戏中也并不会去要求更高的计算精度,因为此时物体的动作肉眼观察起来并不会感到奇怪,这就足够了。但如果是球类游戏等对物体运动的正确性要求很高的话,上文采用的 Δt = 1 就显得精度不足了。此时为了提高精度,一般有以下三个方法。
① 使用精度更高的高阶近似求解微分方程,比如龙格 - 库塔法(Runge-Kutta methods)等。
② 使用线性多步法(Linear multistep method)计算,即不光使用前一次的值,还使用前两次或更早的值进行计算,比如 Adams-Bashforth 法等。
③ 在一帧内多次使用欧拉法计算,缩小Δt。
方法①是偏重理论、数学层面的方法。无论是使用龙格 - 库塔法或者更高阶的近似(如 4 阶),在同样的时间间隔 Δt 内都能得到比欧拉法更好的精度,这也是在模拟实验时常使用的方法。但是实际上在使用龙格 - 库塔法时,代码的可维护性会下降,因为龙格 - 库塔法在理论上要比欧拉法复杂很多,如果不是高校物理专业的人可能很难运用自如。而龙格 - 库塔法同时还存在很多版本的算法,可能会造成混乱。因此如果代码需要让自己以外的人(比如对物理不够熟悉的人)维护,考虑到代码的可维护性,应当慎重考虑是否真的需要采用龙格 - 库塔法。
方法②的 Adams-Bashforth 法理论上比龙格 - 库塔法略简单一些,也没有那么多不同版本的算法,代码可维护性会好一些。但是毫无疑问还是要比欧拉法复杂,在使用时也需要斟酌。
方法③采用的是容易理解的欧拉法,从程序的可维护性上来说应该是最好的方法。比如将欧拉法等式的 Δt 设为 0.1,进行 10 次循环,就可以很简单地得到高精度的运算结果,而在时间精度提高的同时,其他物体的运动是否正确也变得更加容易判断。虽然这样做可能会花费更多的计算时间,但是现在的计算机性能都非常高,游戏中比起物体运动的计算等,渲染其实更加消耗时间,因此个人认为如果游戏中需要高精度的物理计算,不要去使用龙格 - 库塔法或 者 Adams-Bashforth 法,重复使用欧拉法就是最好的方法。因此这里不再列举龙格 - 库塔法及 Adams-Bashforth 法的详细公式,对这些高级方法有兴趣的读者,不妨自己去深入研究一下。}

我要回帖

更多关于 草酸稀释会冒烟吗 的文章

更多推荐

版权声明:文章内容来源于网络,版权归原作者所有,如有侵权请点击这里与我们联系,我们将及时删除。

点击添加站长微信