Java软光栅渲染器-三维向量
目标
- 定义三维向量(Vector3f)类,实现向量的常用计算方法。
- 扩展实现二维向量、四维向量。
源代码:
实现
三维向量
三维向量类的基本定义为:
public class Vector3f {
public float x, y, z;
public Vector3f() {
x = y = z = 0;
}
public Vector3f(float x, float y, float z) {
this.x = x;
this.y = y;
this.z = z;
}
}
Vector3f 可以用来表示三维空间中的一个“点”,这时 (x, y, z) 描述了该“点”在自身坐标系统中的相对位置。
Vector3f 也可以用来表示方向,看作是从原点 (0, 0, 0) 出发,指向 (x, y, z) 的箭头。这个“箭头”具有长度,也称为向量的模。
零向量
3D零向量表示为 (0, 0, 0),它是唯一一个长度为0的向量。
public final static Vector3f ZERO = new Vector3f(0, 0, 0);
负向量
把向量的各个分量求负,可得其负向量。简单地说就是 x + (-x) = 0。
负向量可以看作与原向量长度相同,但是方向相反的向量。
/**
* 求负向量
*
* @return
*/
public Vector3f negate() {
return new Vector3f(-x, -y, -z);
}
/**
* 求负向量
*
* @return
*/
public Vector3f negateLocal() {
x = -x;
y = -y;
z = -z;
return this;
}
注意,这里我学习 jMonkeyEngine 写了2个不同的实现。
- negate() 方法不会改变原向量,而是返回一个新的向量,它是原向量的负向量;
- negateLocal() 方法会改变原向量自身的值,并返回该向量本身。
在进行Java游戏开发时,应该尽量避免实例化新的对象,以减少GC的工作量。在下文出现的其它方法中,如果不做特别声明,名为 xxxLocal() 的方法都意味着不会返回新的对象,而是修改当前对象的值。
向量大小(长度或模)
计算向量各分量的平方和,然后开根号即可。
/**
* 返回向量的长度
*
* @return
*/
public float length() {
return (float) Math.sqrt(x * x + y * y + z * z);
}
开根号是一个相当昂贵的运算。如果只是想比较两个向量的大小,可以直接比较不开根号的值。若要判断向量长度是否为 1 或 0,也不需要开根号,因为1和0的平方等于它们本身。
所以,一般在3D数学库中,会提供这样一个方法,来返回未开根号的平方和:
/**
* 返回向量长度的平方
*
* @return
*/
public float lengthSquared() {
return x * x + y * y + z * z;
}
出于性能考虑,我并没有在 length() 方法中调用 lengthSquared() 方法,因为调用方法时虚拟机会创建栈帧、压栈、弹栈。虽然这些操作的时间很短暂,但是累积起来也会影响程序的性能。
在后面的方法中,如无必要,也会尽量减少方法的调用。
标量与向量的乘法
标量与向量的乘法非常直接,将向量的每个分量都与标量相乘即可。
/**
* 标量乘法
*
* @param scalor
* @return
*/
public Vector3f mult(float scalor) {
return new Vector3f(x * scalor, y * scalor, z * scalor);
}
/**
* 标量乘法
*
* @param scalor
* @return
*/
public Vector3f multLocal(float scalor) {
x *= scalor;
y *= scalor;
z *= scalor;
return this;
}
标准化向量
对于许多向量,我们只关心它的方向而不关心其大小。如:“我面向的是什么方向?”,这样的情况下使用单位向量更为简单。
单位向量就是长度为1的向量。单位向量经常也被称作标准化向量或更简单地称为 “法线”。
一般我会把与三个坐标轴平行的单位向量定义为常量。
public final static Vector3f UNIT_X = new Vector3f(1, 0, 0);
public final static Vector3f UNIT_Y = new Vector3f(0, 1, 0);
public final static Vector3f UNIT_Z = new Vector3f(0, 0, 1);
对于任意非零向量v,都可以计算出一个与它方向相同的单位向量。这个过程称作向量的“标准化”。要标准化向量,将向量除以它的大小(模)即可。
/**
* 求单位向量
*/
public Vector3f normalize() {
float length = x * x + y * y + z * z;
if (length != 1f && length != 0f) {
length = (float) (1.0 / Math.sqrt(length));
return new Vector3f(x * length, y * length, z * length);
}
return new Vector3f(x, y, z);
}
/**
* 求单位向量
*/
public Vector3f normalizeLocal() {
float length = x * x + y * y + z * z;
if (length != 1f && length != 0f) {
length = (float) (1.0 / Math.sqrt(length));
x *= length;
y *= length;
z *= length;
}
return this;
}
注意,这里又用了一个技巧来提升性能。计算机中的除法比乘法昂贵得多,因此 normalize() 方法先计算了 length 的倒数,再做了三次乘法,这样可以提升计算的速度。
零向量不能被标准化。数学上是不允许的,因为这将导致除零。几何上也没有意义,因为零向量没有方向。
向量的加法和减法
向量的加法运算法则很简单:两个向量相加,将对应的分量相加即可。
/**
* 向量加法
*
* @param v
* @return
*/
public Vector3f add(Vector3f v) {
return new Vector3f(x + v.x, y + v.y, z + v.z);
}
/**
* 向量加法
* @param v
* @return
*/
public Vector3f addLocal(Vector3f v) {
x += v.x;
y += v.y;
z += v.z;
return this;
}
减法解释为负向量,a-b=a+(-b)。
/**
* 向量减法
*
* @param v
* @return
*/
public Vector3f subtract(Vector3f v) {
return new Vector3f(x - v.x, y - v.y, z - v.z);
}
/**
* 向量减法
*
* @param v
* @return
*/
public Vector3f subtractLocal(Vector3f v) {
x -= v.x;
y -= v.y;
z -= v.z;
return this;
}
应该注意以下几点:
- 向量不能与标量或维数不同的向量相加减
- 和标量乘法一样,向量加法满足交换律,但是向量减法不满足交换律。永远有 a+b=b+a,但 a-b=-(b-a),仅当a=b时,a-b=b-a。
向量a和b相加的几何解释为:平移向量,使向量a的头连接向量b的尾,接着从a的尾向b的头画一个向量。这就是向量加法的“三角形法则”。
向量的减法与之类似。
一个点到另一个点的向量
计算一个点到另一个点的位移是一种非常普遍的需求,可以使用三角形法则和向量减法来解决这个问题。下图展示了怎样用a-b来计算从点B到A的位移向量。
如图,将点A、B分别理解为从原点O开始的向量a、b,B到A的位移就可以用向量减法a-b来计算。
注意,减法a-b代表了从B到A的向量。简单地求“两点之间”的向量是没有意义的,因为没有指明方向。求一个点到另一个点的向量才有实际意义。
一个点到另一个点的线性插值
计算两点之间的向量很简单,如果需求改变一下,想要求两点之间某一点的向量呢?
例如:有一个物体,从起点出发向终点做匀速直线运动。走完全程需要的总时间为37秒,那么第11秒的时候这个物体运动到了什么位置?
把起点和终点看作两个向量,物体其实就是在从起点到终点的向量上运动。以该向量的总长度为100%,物体已经走完的路程为 t (0~100%),可得插值点的计算公式为:
interpolate = t * (final - begin)
经过化简,这个公式可以改写为:
interpolate = t * final + (1 - t) * begin
这个化简公式的几何意义也很简单:过插值点作一条平行于起点向量的辅助线,与终点向量所在的边相交。从交点的位置,可以得到两个更小的向量,插值点向量正好是这两个向量的和。根据相似三角形定理,可知这两个向量分别是 t * final
和 (1 - t) * begin
。
代码实现如下:
/**
* 在当前向量与final向量之间线性插值。
*
* this=(1-changeAmnt)*this + changeAmnt * finalVec
* @param finalVec 终向量
* @param changeAmnt 插值系数,取值范围为 0.0 - 1.0。
*/
public Vector3f interpolateLocal(Vector3f finalVec, float changeAmnt) {
this.x=(1-changeAmnt)*this.x + changeAmnt*finalVec.x;
this.y=(1-changeAmnt)*this.y + changeAmnt*finalVec.y;
this.z=(1-changeAmnt)*this.z + changeAmnt*finalVec.z;
return this;
}
/**
* 在当开始向量与终向量之间线性插值。
* this=(1-changeAmnt)*beginVec + changeAmnt * finalVec
* @param beginVec 开始向量(changeAmnt = 0)
* @param finalVec 终向量(changeAmnt = 1)
* @param changeAmnt 插值系数,取值范围为 0.0 - 1.0。
*/
public Vector3f interpolateLocal(Vector3f beginVec,Vector3f finalVec, float changeAmnt) {
this.x=(1-changeAmnt)*beginVec.x + changeAmnt*finalVec.x;
this.y=(1-changeAmnt)*beginVec.y + changeAmnt*finalVec.y;
this.z=(1-changeAmnt)*beginVec.z + changeAmnt*finalVec.z;
return this;
}
距离公式
距离公式用来计算两点之间的距离。从几何意义上来说,两点之间的距离等于从一个点到另一个点的向量的长度。先计算从a到b的向量d,然后再计算d的长度即可。
注意,哪个点是a和哪个点是b并不重要,因为我们只关心向量d的长度,不关心它的方向。
/**
* 求两点之间的距离
*
* @param v
* @return
*/
public float distance(Vector3f v) {
double dx = x - v.x;
double dy = y - v.y;
double dz = z - v.z;
return (float) Math.sqrt(dx * dx + dy * dy + dz * dz);
}
/**
* 求两点之间距离的平方
*
* @param v
* @return
*/
public float distanceSquared(Vector3f v) {
double dx = x - v.x;
double dy = y - v.y;
double dz = z - v.z;
return (float) (dx * dx + dy * dy + dz * dz);
}
向量点乘
向量的点乘也称作内积。向量点乘就是对应分量乘积的和,其结果是一个标量。
/**
* 向量点乘(内积)
*
* @param v
* @return
*/
public float dot(Vector3f v) {
return x * v.x + y * v.y + z * v.z;
}
点乘等于向量长度与向量夹角的cos值的积。
a dot b = |a|*|b|*cos(θ)
这个公式很有用,如果a、b都是单位向量,点乘就可以直接算出它们之间的 cos 值。根据cos值,即可以计算两个向量之间的夹角:
- 若 cos > 0。说明a和b的夹角 0° <= θ < 90°;
- 若 cos = 0,说明a和b正交(垂直),即 θ ≈ 90°;
- 若 cos < 0,说明a和b的夹角 90° < θ <= 180°。
向量的大小并不影响点乘结果的符号,所以根据cos的符号就可以判断a和b的大致方向。
向量之间的夹角
利用点乘,很容易求得两个单位向量间的夹角。
/**
* 求两个向量之间的夹角(弧度制)
* 注意:参与运算的两个向量都应该是单位向量
*
* @param v
* @return
*/
public float angleBetween(Vector3f v) {
float dotProduct = x * v.x + y * v.y + z * v.z;
float angle = (float) Math.acos(dotProduct);
return angle;
}
向量投影
已知两个向量a、b,可以把a分解成两个向量,一个垂直于b,另一个平行于b。平行于b的那个向量称为a在b上的投影。
根据向量点乘公式,易得两个向量夹角的 cos 值,根据 cos 值可以进一步求得向量的投影。
/**
* 向量投影
*
* @param v
* @return 返回一个新的向量,它平行于另一个向量。
*/
public Vector3f project(Vector3f v){
float n = x * v.x + y * v.y + z * v.z; // A . B
float d = v.lengthSquared(); // |B|^2
float scalor = n / d;
return new Vector3f(v.x * scalor, v.y * scalor, v.z * scalor);
}
/**
* 向量投影
* @param v
* @return 返回一个新的向量,它平行于另一个向量。
*/
public Vector3f projectLocal(Vector3f v){
float n = this.dot(v); // A . B
float d = v.lengthSquared(); // |B|^2
float scalor = n / d;
x = v.x * scalor;
y = v.y * scalor;
z = v.z * scalor;
return this;
}
向量叉乘
叉乘将会得到一个新的向量,它垂直于原来的两个向量,其长度 |a×b| 正好是以向量a、b位两边的平行四边形的面积。叉乘的这种特性,经常用于求三角形的表面法线。
/**
* 向量叉乘(外积)
*
* @param v
* @return 返回一个新的向量,它垂直于当前两个向量。
*/
public Vector3f cross(Vector3f v) {
float rx = y * v.z - z * v.y;
float ry = z * v.x - x * v.z;
float rz = x * v.y - y * v.x;
return new Vector3f(rx, ry, rz);
}
/**
* 向量叉乘(外积)
*
* @param v
* @return 返回一个新的向量,它垂直于当前两个向量。
*/
public Vector3f crossLocal(Vector3f v) {
float tempX = y * v.z - z * v.y;
float tempY = z * v.x - x * v.z;
z = x * v.y - y * v.x;
x = tempX;
y = tempY;
return this;
}
已知 a×b 垂直于a、b。但是垂直于a、b又两个方向。a×b指向哪个方向呢?通过将a的头与b的尾相接,并检查从a到b是顺时针还是逆时针,能够确定a×b的方向。
在右手坐标系中,如果a和b呈逆时针,则a×b指向您;如果a和b呈顺时针,则a×b远离您。
在左手坐标系中,则正好相反。如果a和b呈逆时针,则a×b远离您;如果a和b呈顺时针,则a×b指向您。
我们可以用右手来辅助记忆。将右手握拳,伸出大拇指指向自己,此时其他四指正好是逆时针方向,大拇指的方向就是a×b的方向。
现在你明白什么叫右手坐标系了吧。:)
向量的乘法
除点乘和叉乘外,还有另一种乘法,计算方法是直接将向量的分量相乘,它的几何意义是把向量按比例放大(缩小)。
/**
* 向量乘法
*
* @param v
* @return
*/
public Vector3f mult(Vector3f v) {
return new Vector3f(x * v.x, y * v.y, z * v.z);
}
/**
* 向量乘法
*
* @param v
* @return
*/
public Vector3f multLocal(Vector3f v) {
x *= v.x;
y *= v.y;
z *= v.z;
return this;
}
向量的除法
向量的除法并没有太大的意义,可以用乘法来代替实现。不过有时为了简化计算工作,也会定义除法的规则。
向量除以标量,等于向量乘以这个标量的倒数:v / s = v * 1/s。
/**
* 标量除法
* @param scalor
* @return
*/
public Vector3f divide(float scalor) {
scalor = 1 / scalor;
return new Vector3f(x * scalor, y * scalor, z * scalor);
}
/**
* 标量除法
* @param scalor
* @return
*/
public Vector3f divideLocal(float scalor) {
scalor = 1 / scalor;
x *= scalor;
y *= scalor;
z *= scalor;
return this;
}
向量a除以b,直接将各分量相除即可。
/**
* 向量除法
* @param v
* @return
*/
public Vector3f divide(Vector3f v) {
return new Vector3f( x / v.x, y / v.y, z / v.z);
}
/**
* 向量除法
* @param v
* @return
*/
public Vector3f divideLocal(Vector3f v) {
x /= v.x;
y /= v.y;
z /= v.z;
return this;
}
注意,我没有在方法内做任何校验,但在做除法运算时应该要确保分母不为0。
总结
本章实现了三维向量的定义和各种运算。很多三维向量运算都可以推导到其他维度,比如二维向量和四维向量。
二维向量叉乘
比较特别的是,二维向量叉乘的结果是一个三维向量,它垂直于二维向量所在的平面。可以把二维向量当做是z分量为0的向量,然后计算叉乘:
(x1, y1, 0) cross (x2, y2, 0) = (0, 0, x1 * y2 - y1 * x2)
按照右手定则,如果向量v1和v2的方向为逆时针,叉乘向量就指向正方向。如果v1和v2的方向为顺时针,叉乘向量就指向负方向。根据 x1 * y2 - y1 * x2
的值,就可以判断向量v1和v2的方向。
在右手坐标系中,如果这个值大于0,说明v1和v2呈逆时针方向。换句话说,点v2位于向量v1的左侧。若值小于0,则说明点v2位于向量v1的右侧;当这个值为0时,说明向量 v1 和 v2 共线。
这个性质,在 Vector2f 类中用于判断向量的相对位置。
/**
* 判别式
*/
public float determinant(Vector2f v) {
return (x * v.y) - (y * v.x);
}
public Vector3f cross(Vector2f v) {
return new Vector3f(0, 0, determinant(v));
}
四维向量初始化
在定义Vector4f时,我学习 Shader 语言中的用法,允许使用三维向量、二维向量来初始化四维向量。
/**
* 使用一个三维向量+一个标量来构造四维向量。
* @param v
* @param w
*/
public Vector4f(Vector3f v, float w) {
this.x = v.x;
this.y = v.y;
this.z = v.z;
this.w = w;
}
/**
* 使用2个二维向量来构造四维向量。
* @param v0
* @param v1
*/
public Vector4f(Vector2f v0, Vector2f v1) {
this.x = v0.x;
this.y = v0.y;
this.z = v1.x;
this.w = v1.y;
}
虽然不知道有什么用,但也许将来模拟着色器的时候用得着吧。