在这一章中,你将学习向量数学的基础知识。在本书余下的部分中,你将编写的大部分代码都依赖于对向量的深刻理解。向量将用于表示位移和方向。
到本章结束时,您将实现一个健壮的向量库,并且能够执行各种向量操作,包括组件式和非组件式操作。
我们将在本章中讨论以下主题:
-
引入向量
-
创建向量
-
了解组件式操作
-
理解非组件式操作
-
插值向量
-
比较向量
-
Exploring more vectors
重要信息:
在本章中,您将学习如何以直观、可视化的方式实现向量,这种方式更多地依赖于代码,而不是数学公式。如果你对数学公式感兴趣或者想尝试一些互动的例子,可以去https://gabormakesgames.com/vectors.html。
什么是向量?向量是数字的 n 元组。它代表一个位移量和一个方向。向量的每个元素通常用下标表示,如 (V 0 、V 1 、V 2 、… V N ) 。在游戏中,向量通常有两个、三个或四个分量。
例如,三维向量测量三个唯一轴上的位移: x 、 y 和 z 。向量的元素通常用它们所代表的轴而不是索引来下标。 (V X 、V Y 、V Z ) 和 (V 0 、V 1 、V 2 ) 可互换使用。
当可视化向量时,它们通常被绘制为箭头。箭头底部的位置无关紧要,因为向量测量的是位移,而不是位置。箭头的末端跟随箭头在每个轴上的位移。
例如,下图中的所有箭头代表相同的向量:
图 2.1:在多个位置绘制的向量(2,5)
每个箭头都具有相同的长度并指向相同的方向,无论它位于何处。在下一节中,您将开始实现将在本书剩余部分中使用的向量结构。
向量将被实现为结构,而不是类。向量结构将包含一个匿名联合,允许向量的组成部分作为一个数组或单个元素被访问。
要声明vec3
结构和函数头,创建一个新文件vec3.h
。在此文件中声明新的vec3
结构。vec3
结构需要三个构造函数——一个默认构造函数,一个将每个组件作为一个元素,一个将指针指向一个浮点数组:
#ifndef _H_VEC3_
#define _H_VEC3_
struct vec3 {
union {
struct {
float x;
float y;
float z;
};
float v[3];
};
inline vec3() : x(0.0f), y(0.0f), z(0.0f) { }
inline vec3(float _x, float _y, float _z) :
x(_x), y(_y), z(_z) { }
inline vec3(float *fv) :
x(fv[0]), y(fv[1]), z(fv[2]) { }
};
#endif
vec3
结构中的匿名联合允许使用.x
、.y
和.z
符号访问数据,或者使用.v
作为连续数组访问数据。在继续实现在vec3
结构上工作的函数之前,您需要考虑比较浮点数以及是否使用ε值。
比较浮点数很困难。不是直接比较两个浮点数,而是需要用一个ε来比较。ε是一个任意小的正数,这是两个数必须被认为是不同数的最小差。在vec3.h
中声明一个ε常数:
#define VEC3_EPSILON 0.000001f
重要提示:
你可以在https://bitbashing.io/comparing-floats.html了解更多关于浮点比较的知识
创建vec3
结构并定义vec3
ε后,您就可以开始执行一些常见的向量操作了。在下一节中,您将从学习和实现几个组件式操作开始。
几个向量运算只是组件式运算。分量操作是对向量的每个分量或两个向量的相似分量执行的操作。相似组件是具有相同下标的组件。您将实现的组件式操作如下:
- 向量加法
- 向量减法
- 向量缩放
- 乘法向量
- 点积
让我们更详细地看看其中的每一个。
将两个向量相加得到第三个向量,它具有两个输入向量的组合位移。向量加法是一种分量运算;要执行它,您需要添加类似的组件。
要可视化两个向量的相加,请在第一个向量的顶端绘制第二个向量的底部。接下来,画一个从第一个向量的底部到第二个向量的顶端的箭头。此箭头表示相加后的向量:
图 2.2:向量加法
要在代码中实现向量加法,请添加输入向量的相似分量。创建新文件,vec3.cpp
。这是定义与vec3
结构相关的函数的地方。别忘了包括vec3.h
。过载+ operator
执行向量加法。别忘了给vec3.h
添加功能签名:
vec3 operator+(const vec3 &l, const vec3 &r) {
return vec3(l.x + r.x, l.y + r.y, l.z + r.z);
}
当考虑向量加法时,记住向量代表位移。当添加两个向量时,结果是两个输入向量的组合位移。
与添加向量一样,减去向量也是一个分量操作。你可以把减去向量想象成把第二个向量的负数加到第一个向量上。当可视化为箭头时,减法从第二个向量的末端指向第一个向量的末端。
若要从视觉上减去向量,请将两个向量放置在同一原点。从第二个箭头的尖端到第一个箭头的尖端画一个向量。结果箭头是减法结果向量:
图 2.3:向量减法
要实现向量减法,请减去类似的分量。通过重载vec3.cpp
中的-
运算符来实现减法功能。别忘了给vec3.h
添加功能声明:
vec3 operator-(const vec3 &l, const vec3 &r) {
return vec3(l.x - r.x, l.y - r.y, l.z - r.z);
}
步骤和逻辑与向量加法非常相似。把向量减法想象成加一个负向量可能会有帮助。
当一个向量被缩放时,它只在的大小上变化,而不是方向上。与加法和减法一样,缩放是一个组件式操作。与加法和减法不同,向量是由标量而不是另一个向量来缩放的。
从视觉上看,缩放后的向量指向与原始向量相同的方向,但长度不同。下图显示了两个向量: (2,1) 和 (2,4) 。两个向量共享同一个方向,但第二个向量的大小较长:
图 2.4:向量缩放
要实现向量缩放,请将向量的每个分量乘以给定的标量值。
通过重载vec3.cpp
中的*
运算符来实现缩放功能。别忘了给vec3.h
添加功能声明:
vec3 operator*(const vec3 &v, float f) {
return vec3(v.x * f, v.y * f, v.z * f);
}
否定一个向量可以通过 -1 缩放向量来完成。当否定一个向量时,该向量保持其大小但改变其方向。
向量乘法可以认为是一个非均匀尺度。不是用标量来缩放向量的每个分量,而是用另一个向量的相似分量来缩放向量的每个分量。
您可以通过重载vec3.cpp
中的*
运算符来实现向量乘法。别忘了给vec3.h
添加功能声明:
vec3 operator*(const vec3 &l, const vec3 &r) {
return vec3(l.x * r.x, l.y * r.y, l.z * r.z);
}
两个向量相乘产生的结果将具有不同的方向和大小。
点积是用来衡量两个向量有多相似。给定两个向量,点积返回标量值。点积的结果具有以下性质:
- 如果向量指向同一个方向,则为正。
- 如果向量指向相反的方向,则为负。
- 如果向量垂直,则为 0 。
如果两个输入向量都有单位长度(您将在本章的法向向量部分了解单位长度向量),点积的范围将是 -1 到 1 。
两个向量 A 和 B 之间的点积等于 A 的长度乘以 B 的长度乘以两个向量之间角度的余弦:
计算点积最简单的方法是对输入向量中相似分量的积求和:
在vec3.cpp
中实现dot
功能。别忘了给vec3.h
添加功能定义:
float dot(const vec3 &l, const vec3 &r) {
return l.x * r.x + l.y * r.y + l.z * r.z;
}
点积是电子游戏中最常用的操作之一。它通常用于检查角度和照明计算。
使用点积,您已经实现了向量的常见分量操作。接下来,您将了解一些可以在向量上执行的非组件式操作。
并非所有向量运算都是分量式的;有些运算需要更多的数学运算。在本节中,您将学习如何实现非基于组件的通用向量操作。这些操作如下:
- 如何求向量的长度
- 法向量是什么
- 如何归一化向量
- 如何求两个向量之间的角度
- 如何投射向量,什么是拒绝
- 如何反映向量
- 什么是叉积以及如何实现它
让我们更详细地看看每一个。
向量表示方向和大小;向量的大小就是它的长度。求向量长度的公式来自三角学。在下图中,二维向量被分解成平行和垂直分量。注意这是如何形成直角三角形的,向量是斜边:
图 2.5:分解成平行和垂直分量的向量
直角三角形斜边的长度可以用勾股定理求出,A2*+B2= C2。只需添加一个 Z 组件—X2+Y2+Z2=长度* 2,该功能就扩展到了三维。
你可能已经注意到了这里的一个模式;向量的平方长度等于其分量之和。这可以表示为点积— 长度 2 (A) =点(A,A) :
重要提示:
求向量的长度涉及到平方根运算,在可能的情况下应该避免。当检查向量的长度时,检查可以在平方空间中进行,以避免平方根。例如,如果要检查向量 A 的长度是否小于 5 ,则可以表示为*(点(A,A) < 5 * 5)* 。
-
为了实现平方长度函数,对向量的每个分量求平方的结果求和。在
vec3.cpp
中实现lenSq
功能。别忘了把功能声明添加到vec3.h
:float lenSq(const vec3& v) { return v.x * v.x + v.y * v.y + v.z * v.z; }
-
To implement the length function, take the square root of the result of the square length function. Take care not to call
sqrtf
with0
. Implement thelenSq
function invec3.cpp
. Don't forget to add the function declaration tovec3.h
:float len(const vec3 &v) { float lenSq = v.x * v.x + v.y * v.y + v.z * v.z; if (lenSq < VEC3_EPSILON) { return 0.0f; } return sqrtf(lenSq); }
重要提示:
你可以通过取两个向量之间差的长度来找到它们之间的距离。例如,浮动距离= len(vec1 - vec2) 。
长度为 1 的向量称为法向向量(或单位向量)。通常,单位向量用于表示没有大小的方向。两个单位向量的点积将始终落在 -1 至 1 范围内。
除了 0 向量之外,任何向量都可以通过按其长度的倒数缩放向量来归一化:
-
在
vec3.cpp
中实现normalize
功能。别忘了把功能声明添加到vec3.h
:void normalize(vec3 &v) { float lenSq = v.x * v.x + v.y * v.y + v.z * v.z; if (lenSq < VEC3_EPSILON) { return; } float invLen = 1.0f / sqrtf(lenSq); v.x *= invLen; v.y *= invLen; v.z *= invLen; }
-
在
vec3.cpp
中实现normalized
功能。别忘了把功能声明添加到vec3.h
:vec3 normalized(const vec3 &v) { float lenSq = v.x * v.x + v.y * v.y + v.z * v.z; if (lenSq < VEC3_EPSILON) { return v; } float invLen = 1.0f / sqrtf(lenSq); return vec3( v.x * invLen, v.y * invLen, v.z * invLen ); }
normalize
函数引用一个向量,并对其进行适当的归一化。另一方面,normalized
函数采用恒定参考,不修改输入向量。相反,它返回一个新向量。
如果两个向量是单位长度,它们之间的角度是它们的点积的余弦:
如果两个向量没有归一化,点积需要除以两个向量长度的乘积:
为了找到实际的角度,而不仅仅是它的余弦,我们需要取两边余弦的倒数,这就是反余弦函数:
在vec3.cpp
中实现angle
功能。别忘了给vec3.h
添加功能声明:
float angle(const vec3 &l, const vec3 &r) {
float sqMagL = l.x * l.x + l.y * l.y + l.z * l.z;
float sqMagR = r.x * r.x + r.y * r.y + r.z * r.z;
if (sqMagL<VEC3_EPSILON || sqMagR<VEC3_EPSILON) {
return 0.0f;
}
float dot = l.x * r.x + l.y * r.y + l.z * r.z;
float len = sqrtf(sqMagL) * sqrtf(sqMagR);
return acosf(dot / len);
}
重要提示:
acosf
函数以弧度为单位返回角度。要将弧度转换为度数,乘以57.2958f
。要将度数转换为弧度,乘以0.0174533f
。
将向量 A 投影到向量 B 上产生一个新向量,该向量在 B 方向上的长度为 A 。可视化向量投影的一个好方法是想象向量 A 正在向量 B 上投射阴影,如图所示:
图 2.6:向量 A 投射阴影到向量 B 上
要计算 A 到 B ( 投影 B A )的投影,向量 A 必须分解为相对于向量 B 的平行和垂直分量。平行分量是 A 在 B 方向上的长度——这是投影。垂直分量是从 A 中减去的平行分量——这是剔除:
图 2.7:显示平行和垂直向量的向量投影和剔除
如果被投影到的向量(在本例中,向量 B )是法向向量,那么求 B 方向上的 A 的长度就是 A 和 B 之间的简单点积。但是,如果两个输入向量都没有归一化,点积需要除以向量 B 的长度(投影到的向量)。
现在 A 相对于 B 的平行分量是已知的,向量 B 可以通过这个分量来缩放。同样,如果 B 不是单位长度,结果将需要除以向量 B 的长度。
拒绝是投射的对立面。要找到 A 到 B 的拒绝,从向量 A 中减去 A 到 B 的投影:
-
在
vec3.cpp
中实现project
功能。别忘了把功能声明添加到vec3.h
:vec3 project(const vec3 &a, const vec3 &b) { float magBSq = len(b); if (magBSq < VEC3_EPSILON) { return vec3(); } float scale = dot(a, b) / magBSq; return b * scale; }
-
在
vec3.cpp
中实现reject
功能。别忘了在vec3.h
:vec3 reject(const vec3 &a, const vec3 &b) { vec3 projection = project(a, b); return a - projection; }
申报该功能
向量投影和拒绝通常用于游戏编程。重要的是它们在健壮的向量库中实现。
向量反射可以表示两种事物之一:镜像反射或反弹反射。下图显示了不同类型的反射:
图 2.8:反射镜和反射镜的比较
反弹反射比镜面反射更有用、更直观。要进行反弹投影,请将向量 A 投影到向量 B 上。这将产生一个指向反射相反方向的向量。否定这个投影,从向量 a 中减去两次。下图演示了这一点:
图 2.9:可视化反弹反射
在vec3.cpp
中实现reflect
功能。别忘了给vec3.h
添加功能声明:
vec3 reflect(const vec3 &a, const vec3 &b) {
float magBSq = len(b);
if (magBSq < VEC3_EPSILON) {
return vec3();
}
float scale = dot(a, b) / magBSq;
vec3 proj2 = b * (scale * 2);
return a - proj2;
}
向量反射对物理和 AI 有用。我们不需要使用反射进行动画,但是如果需要的话,实现这个功能就好了。
当给定两个输入向量时,叉积返回垂直于两个输入向量的第三个向量。叉积的长度等于两个向量形成的平行四边形的面积。
下图从视觉上展示了叉积的样子。输入向量不必相隔 90 度,但这样更容易可视化:
图 2.10:可视化交叉产品
寻找叉积涉及一些矩阵数学,这将在下一章更深入地讨论。现在,您需要创建一个 3x3 矩阵,上面一行是结果向量。第二行和第三行应该用输入向量填充。结果向量的每个分量的值是矩阵中该元素的次数值。
3x3 矩阵中一个元素的次幂到底是多少?它是一个更小的 2x2 子矩阵的行列式。假设您想找到第一个分量的值,忽略第一行和第一列,这将产生一个较小的 2x2 子矩阵。下图显示了每个组件的较小子矩阵:
图 2.11:每个组件的子矩阵
要找到 2x2 矩阵的行列式,需要交叉相乘。将左上角和右下角的元素相乘,然后减去右上角和左下角元素的乘积。下图显示了结果向量的每个元素:
图 2.12:结果向量中每个分量的行列式
在vec3.cpp
中实现cross
产品。别忘了给vec3.h
添加功能声明:
vec3 cross(const vec3 &l, const vec3 &r) {
return vec3(
l.y * r.z - l.z * r.y,
l.z * r.x - l.x * r.z,
l.x * r.y - l.y * r.x
);
}
点积与两个向量之间角度的余弦有关系,叉积与两个向量之间角度的正弦有关系。两个向量之间的叉积的长度为两个向量长度的乘积,用两个向量之间角度的正弦值表示:
在下一节中,您将学习如何使用三种不同的技术在向量之间进行插值。
通过缩放两个向量之间的差值并将结果加回原始向量,可以对两个向量进行线性插值。这种线性插值通常缩写为lerp
。到lerp
的量是介于 0 和 1 之间的归一化值;该归一化值通常由字母 t 表示。下图显示了两个向量之间的lerp
以及 t 的几个值:
图 2.13:线性插值
当 t = 0 时,插值向量与起始向量相同。当 t = 1 时,插值向量与结束向量相同。
在vec3.cpp
中实现lerp
功能。别忘了给vec3.h
添加功能声明:
vec3 lerp(const vec3 &s, const vec3 &e, float t) {
return vec3(
s.x + (e.x - s.x) * t,
s.y + (e.y - s.y) * t,
s.z + (e.z - s.z) * t
);
}
在两个向量之间进行线性插值将总是采用从一个向量到另一个向量的最短路径。有时候,最短的路不是最好的路;相反,您可能需要沿着最短的弧在两个向量之间进行插值。在最短弧上插值称为球面线性插值(slerp
)。下图显示了 t 的几个值的slerp
和lerp
过程之间的差异:
图 2.14:比较 slerp 和 lerp
要实现slerp
,找到两个输入向量之间的角度。假设角度已知,slerp
的公式如下
在vec3.cpp
中实现slerp
功能。别忘了给vec3.h
添加函数声明。注意当 t 的值接近 0 时,因为slerp
会产生意想不到的结果。当 t 的值接近 0 时,退回到lerp
或正常化 lerp ( nlerp
)(接下来将介绍):
vec3 slerp(const vec3 &s, const vec3 &e, float t) {
if (t < 0.01f) {
return lerp(s, e, t);
}
vec3 from = normalized(s);
vec3 to = normalized(e);
float theta = angle(from, to);
float sin_theta = sinf(theta);
float a = sinf((1.0f - t) * theta) / sin_theta;
float b = sinf(t * theta) / sin_theta;
return from * a + to * b;
}
最后要覆盖的插值方法是nlerp
。nlerp
近似于slerp
。与slerp
不同,nlerp
的速度不是恒定的。nlerp
比slerp
快很多,更容易实现;将lerp
的结果正常化即可。下图比较了lerp
、slerp
和nlerp
,其中 t = 0.25 :
图 2.15:比较 lerp、slerp 和 nlerp
在vec3.cpp
中实现nlerp
功能。别忘了给vec3.h
添加功能声明:
vec3 nlerp(const vec3 &s, const vec3 &e, float t) {
vec3 linear(
s.x + (e.x - s.x) * t,
s.y + (e.y - s.y) * t,
s.z + (e.z - s.z) * t
);
return normalized(linear);
}
一般来说nlerp
是比slerp
更好的选择。这是一个非常接近的近似值,而且计算起来要便宜得多。唯一有意义的使用slerp
来代替的时间是如果需要恒定的插值速度。在本书中,你将使用lerp
和nlerp
在向量之间插值。
在下一节中,您将学习如何使用ε值来比较等式和不等式的向量。
需要实现的最后一个操作是向量比较。比较是一个组件式操作;必须使用ε来比较每个元素。测量两个向量是否相同的另一种方法是减去它们。如果它们相等,减去它们会得到一个没有长度的向量。
让vec3.cpp
的==
和!=
操作员超载。别忘了给vec3.h
添加函数声明:
bool operator==(const vec3 &l, const vec3 &r) {
vec3 diff(l - r);
return lenSq(diff) < VEC3_EPSILON;
}
bool operator!=(const vec3 &l, const vec3 &r) {
return !(l == r);
}
重要提示:
找到正确的ε值用于比较操作是困难的。在本章中,您将0.000001f
声明为ε。这个值是一些反复试验的结果。要了解更多关于比较浮点值的信息,请查看https://bitbashing.io/comparing-floats.html。
在下一节中,您将实现两个和四个分量的向量。这些向量将仅用作存储数据的方便方式;他们实际上不需要在他们身上实现任何数学运算。
在本书后面的某个时候,你也需要利用二分量和四分量向量。二分量向量和四分量向量不需要定义任何数学函数,因为它们将专门用作容器,用于将数据传递给图形处理器。
与您实现的三分量向量不同,二分量向量和四分量向量需要同时作为整数向量和浮点向量存在。为了避免代码重复,这两种结构都将使用模板来实现:
-
创建一个新文件
vec2.h
,并添加vec2
结构的定义。所有的vec2
构造函数都是内联的;不需要cpp
文件。TVec2
结构是模板化的,typedef
用于声明vec2
和ivec2
:template<typename T> struct TVec2 { union { struct { T x; T y; }; T v[2]; }; inline TVec2() : x(T(0)), y(T(0)) { } inline TVec2(T _x, T _y) : x(_x), y(_y) { } inline TVec2(T* fv) : x(fv[0]), y(fv[1]) { } }; typedef TVec2<float> vec2; typedef TVec2<int> ivec2;
-
同样,创建一个
vec4.h
文件,它将保存vec4
结构:template<typename T> struct TVec4 { union { struct { T x; T y; T z; T w; }; T v[4]; }; inline TVec4<T>(): x((T)0),y((T)0),z((T)0),w((T)0){} inline TVec4<T>(T _x, T _y, T _z, T _w) : x(_x), y(_y), z(_z), w(_w) { } inline TVec4<T>(T* fv) : x(fv[0]), y(fv[ ]), z(fv[2]), w(fv[3]) { } }; typedef TVec4<float> vec4; typedef TVec4<int> ivec4; typedef TVec4<unsigned int> uivec4;
vec2
、ivec2
、vec4
和ivec4
结构的声明都与vec3
结构的声明非常相似。所有这些结构都可以使用组件下标或作为指向内存线性数组的指针来访问。它们也都有类似的构造函数。
在本章中,您已经学习了创建健壮动画系统所需的向量数学。动画是一个数学很重的话题;你在这一章学到的技能是完成这本书其余部分所必需的。您实现了三分量向量的所有常见向量运算。vec2
和vec4
结构不像vec3
那样有完整的实现,但是它们只用于向 GPU 发送数据。
在下一章中,您将通过学习矩阵来继续了解更多与游戏相关的数学知识。