目标
- 定义虚拟摄像机
- 计算观察变换矩阵
- 计算透视投影变换矩阵
- 计算视口变换矩阵
- 显示一个立方体的线框模型
本章相关源码:
实现
这一部分的实现有点复杂,主要是各种变换矩阵的演算。我周五晚上第一次实现这个功能的时候,照抄jME3的代码,但是结果并不让人满意。
后来我感觉不弄明白计算方法是行不通的。于是花了两天时间学习各种资料,并在纸上推算了一遍,周日才把程序改对了。错误的原因,仅仅是写错了一个数的正负号,以及在投影矩阵中用 height/width 的值代替了 width/height 的值。修正后画面是这样的:
这部分的数学推算,主要参考了这些资料:
- 《3D数学基础:图形与游戏开发》,第15章 图形数学
- 《3D游戏与计算机图形学中的数学方法》,第5章 3D引擎中的几何学
- 《计算机图形学(第四版)》,第10章 三维观察
- OpenGL学习脚印: 投影矩阵和视口变换矩阵(math-projection and viewport matrix)
- 从零实现3D图像引擎:(12)构建支持欧拉和UVN的相机系统
- 从零实现3D图像引擎:(13)把宽高比、透视投影矩阵、屏幕变换矩阵说透
- 从零实现3D图像引擎:(14)背面消隐的三大陷阱
- View Transform(视图变换)详解
- 透视投影详解
- 世界变换、观察变换、投影变换 矩阵
在这个基础上,很快又实现了背面消隐。
具体的实现过程如下。
定义摄像机
这一节主要是说明 Camera 类中各参数的用途。
位置和角度
3D引擎中的虚拟摄像机代表了观察三维场景的“眼睛”,屏幕上3D画面实际上是通过这个“眼睛”看到的。从不同位置、不同角度去观察同一个物体时,屏幕中所呈现的画面是不一样的。在定义摄像机时,需要记录相机所处的位置,以及观察物体的角度。利用这些信息,就可以计算出“观察变换矩阵”(view transform matrix)。
package net.jmecn.renderer;
import net.jmecn.math.Matrix4f;
import net.jmecn.math.Vector3f;
/**
* 摄像机
*
* @author yanmaoyuan
*
*/
public class Camera {
/**
* 观察位置。初始位置位于Z轴的正方向,离世界空间中心点10个单位距离。
*/
private Vector3f location = new Vector3f(0, 0, 10);
/**
* 观察的方向。默认为Z轴负方向
*/
private Vector3f direction = new Vector3f(0, 0, -1);
/**
* 观察向上向量。默认为Y轴正方向
*/
private Vector3f up = new Vector3f(0, 1, 0);
/**
* 摄像机的UVN系统
*/
private Vector3f uAxis = new Vector3f(1, 0, 0);
private Vector3f vAxis = new Vector3f(0, 1, 0);
private Vector3f nAxis = new Vector3f(0, 0, 1);
/**
* 观察变换矩阵
*/
private Matrix4f viewMatrix = new Matrix4f();
}
“观察位置”只需要一个3D向量就可以记录。“观察角度”需要根据“视线”的方向向量和一个向上的方向向量来确定。根据这个两个向量,就可以计算出被称为“UVN系统”的一组基底,它们是互相正交的三个单位向量,分别指向相机右方、上方和后方,正好构成一个OpenGL右手坐标系。
观察范围
除此之外,摄像机还应该定义观察范围,或者说“视锥”。这些参数将用于计算投影变换矩阵(projection transform matrix)。
public class Camera {
// 前略..
/**
* 组成视锥的六个平面
*/
private float near = 1f; // 近平面距离
private float far = 1000f; // 远平面距离
private float left; // 左平面距离
private float right; // 右平面距离
private float top; // 上平面距离
private float bottom; // 下平面距离
/**
* 视野范围 70°
*/
private float fov = (float) Math.toRadians(70);
private float aspect;// 屏幕高宽比 width / height
/**
* 是否平行投影
*/
private boolean parallel = false;
/**
* 投影变换矩阵
*/
private Matrix4f projectionMatrix = new Matrix4f();
/**
* 观察-投影 变换矩阵
*/
private Matrix4f viewProjectionMatrix = new Matrix4f();
}
视口
最后,在绘图时还需要把投影后的顶点正确显示到宽为width、高为height的屏幕上。这需要一个称为屏幕空间变换矩阵的东西,或者叫做视口变换矩阵。
public class Camera {
// 前略..
/**
* 屏幕的宽度和高度
*/
private int width;
private int height;
/**
* 视口变换矩阵
*/
private Matrix4f viewportMatrix = new Matrix4f();
}
初始化
创建摄像机时,一般需要给这些参数赋予初值,并提供方法来让用户调整参数。不过为了快速测试功能,目前我会在代码中把这些变量的值写死,等实现了一系列计算后再重构代码。
现在构造方法中只需要初始化 width和height即可,因为这与用户实际创建的窗口分辨率有关。根据这两个变量就可以算出屏幕的宽高比(aspect),用于计算投影变换矩阵。
/**
* 初始化摄像机
* @param width
* @param height
*/
public Camera(int width, int height) {
this.width = width;
this.height = height;
this.aspect = (float) width / height;// 屏幕宽高比
}
方法说明
Camera中主要是一些用于计算变换矩阵的方法,再就是各种getter和setter方法。
/**
* 观察-投影 变换矩阵
*/
public void updateViewProjectionMatrix() {
updateViewMatrix();
updateProjectionMatrix();
projectionMatrix.mult(viewMatrix, viewProjectionMatrix);
}
/**
* 观察变换矩阵
*/
public void updateViewMatrix() {
// TODO 计算观察变换矩阵
}
/**
* 投影变换矩阵
*/
public void updateProjectionMatrix() {
if (!parallel) {
// TODO 计算透视投影
} else {
// TODO 计算正交投影
}
}
/**
* 视口变换矩阵
*/
public void updateViewportMatrix() {
// TODO 计算视口变换矩阵
}
getter和setter方法:
/**
* 获取位置
* @return
*/
public Vector3f getLocation() {
return location;
}
/**
* 设置观察位置
* @param location
*/
public void setLocation(Vector3f location) {
this.location.set(location);
updateViewMatrix();
projectionMatrix.mult(viewMatrix, viewProjectionMatrix);
}
/**
* 获取观察方向
* @return
*/
public Vector3f getDirection() {
return direction;
}
/**
* 设置观察方向
* @param direction
*/
public void setDirection(Vector3f direction) {
this.direction.set(direction);
updateViewMatrix();
projectionMatrix.mult(viewMatrix, viewProjectionMatrix);
}
/**
* 获取观察方向的正右方向量
* @return
*/
public Vector3f getRightVector() {
return uAxis;
}
/**
* 获取观察方向的正上方向量
* @return
*/
public Vector3f getUpVector() {
return vAxis;
}
/**
* 是否平行投影
* @return
*/
public boolean isParallel() {
return parallel;
}
/**
* 设置平行投影
* @param parallel
*/
public void setParallel(boolean parallel) {
this.parallel = parallel;
}
/**
* 获取观察变换矩阵
* @return
*/
public Matrix4f getViewMatrix() {
return viewMatrix;
}
/**
* 获取投影变换矩阵
* @return
*/
public Matrix4f getProjectionMatrix() {
return projectionMatrix;
}
/**
* 获取观察投影变换矩阵
* @return
*/
public Matrix4f getViewProjectionMatrix() {
return viewProjectionMatrix;
}
/**
* 获得视口变换矩阵
* @return
*/
public Matrix4f getViewportMatrix() {
return viewportMatrix;
}
观察变换矩阵
观察变换矩阵的作用,是把顶点坐标从世界空间变换到相机空间。换句话说,就是以相机所处位置(或者眼睛的位置)为原点,变换了观察角度的空间。
这个变换矩阵可以分解成两个变换:其一为旋转变换,其二为平移变换。
旋转变换
根据摄像机的观察方向(direction),和一个向上的参考方向,就可以计算出一个3x3旋转变换矩阵。
|u.x v.x n.x|
M(rotate) = |u.y v.y n.y|
|u.z v.z n.z|
这是一个正交矩阵,也是一个单位矩阵,直接将其转置就可以得到世界空间相对摄像机的旋转矩阵。
|u.x u.y u.z|
M(rotate)' = |v.x v.y v.z|
|n.x n.y n.z|
平移变换
相对世界空间来说,这个摄像机的平移变换就是 location(x, y, z)。
若摄像机的观察方向发生了旋转,就应该以根据旋转后的u、v、n向量作为基底,计算(x, y, z)分别在u、v、n方向上的分量。
点乘即可:
x' = (ux, uy, uz) dot (x, y, z)
y' = (vx, vy, vz) dot (x, y, z)
z' = (nx, ny, nz) dot (x, y, z)
需要注意的是,我们要计算的是以摄像机观察位置为原点的平移变换,因此x'、y'、z'需要取负值。
结果
把这两个变换组合起来,写成一个4x4矩阵,就是:
|u.x u.y u.z -x'|
M = |v.x v.y v.z -y'|
|n.x n.y n.z -z'|
| 0 0 0 1 |
实现代码:
/**
* 观察变换矩阵
*/
public void updateViewMatrix() {
// 计算摄像机的旋转矩阵
direction.cross(up, uAxis);
uAxis.cross(direction, vAxis);
nAxis.set(-direction.x, -direction.y, -direction.z);
// 计算摄像机旋转后的平移变换
float x = uAxis.dot(location);
float y = vAxis.dot(location);
float z = nAxis.dot(location);
// 计算观察变换矩阵
float m00 = uAxis.x, m01 = uAxis.y, m02 = uAxis.z, m03 = -x;
float m10 = vAxis.x, m11 = vAxis.y, m12 = vAxis.z, m13 = -y;
float m20 = nAxis.x, m21 = nAxis.y, m22 = nAxis.z, m23 = -z;
float m30 = 0f, m31 = 0f, m32 = 0f, m33 = 1f;
viewMatrix.set(m00, m01, m02, m03, m10, m11, m12, m13, m20, m21, m22, m23, m30, m31, m32, m33);
}
投影变换矩阵
3D物体看起来应该近大远小,这是通过透视(Perspective)投影(Projection)变换来实现的。
下面的代码,根据用户设置的parallel变量,决定使用透视投影还是正交投影。注意正交投影这里的left、right、top、bottom参数是我随便设的,没有什么实际意义。
/**
* 投影变换矩阵
*/
public void updateProjectionMatrix() {
if (!parallel) {
// 透视投影
setPerspective(fov, aspect, near, far);
} else {
// 正交投影
left = -0.5f;
right = 0.5f;
top = 0.5f;
bottom = -0.5f;
setOrthographic(left, right, bottom, top, near, far);
}
}
透视投影矩阵
根据视野范围、视锥平面的宽高比、近平面、远平面,就可以计算出透视投影矩阵。
/**
* 透视投影
* @param fov 视野范围(弧度制)
* @param aspect 视锥平面的宽高比(w/h)
* @param near 近平面距离
* @param far 远平面距离
*/
public void setPerspective(float fov, float aspect, float near, float far) {
// X方向的缩放比
float zoomX = 1f / (float)Math.tan(fov * 0.5f);
// Y方向的缩放比
float zoomY = zoomX * aspect;
float m00 = zoomX, m01 = 0, m02 = 0, m03 = 0;
float m10 = 0, m11 = zoomY, m12 = 0, m13 = 0;
float m20 = 0, m21 = 0, m22 = -(far+near)/(far-near), m23 = -2*far*near/(far-near);
float m30 = 0, m31 = 0, m32 = -1, m33 = 0;
projectionMatrix.set(m00, m01, m02, m03, m10, m11, m12, m13, m20, m21, m22, m23, m30, m31, m32, m33);
}
另一种透视投影矩阵
这个公式与上面一种是等价的。在上面的方法中,fov的主要作用是计算视锥的正切(tan),进一步计算X方向的焦距,或者叫缩放比(zoomX)。而aspect则用于计算Y方向的焦距(zoomY)。
根据三角函数中正切的定义,可以直接根据视锥平面的宽度(width)和近平面的距离(near)来计算正切值。
tan(fov/2) = 0.5f * width / near
= 0.5f * (right - left) / near
而X方向的焦距就有:
zoomX = 1f / tan(fov/2)
= 2f * near / (right - left)
至于屏幕的宽高比,也可以根据 aspect = (right - left ) / (top - bottom) 求出,结果就能算出Y方向的缩放比:
zoomY = 2 * near / (top - bottom)
剩下的就跟前一个公式一样了。实现代码如下:
/**
* 透视投影
* @param left
* @param right
* @param bottom
* @param top
* @param near
* @param far
*/
public void setPerspective(float left, float right, float bottom, float top, float near, float far) {
// X方向的缩放比
float zoomX = 2f * near / (right - left);
// Y方向的缩放比
float zoomY = 2f * near / (top - bottom);
float m00 = zoomX, m01 = 0, m02 = 0, m03 = 0;
float m10 = 0, m11 = zoomY, m12 = 0, m13 = 0;
float m20 = 0, m21 = 0, m22 = -(far+near)/(far-near), m23 = -2*far*near/(far-near);
float m30 = 0, m31 = 0, m32 = -1, m33 = 0;
projectionMatrix.set(m00, m01, m02, m03, m10, m11, m12, m13, m20, m21, m22, m23, m30, m31, m32, m33);
}
无穷远透视投影
当 far -> ∞ 时,可得无穷远投影矩阵的计算公式。这个投影矩阵在计算阴影时有用。
/**
* 透视投影(无穷远)
* @param fov 视野范围(弧度制)
* @param aspect 视锥平面的宽高比(w/h)
* @param near 近平面距离
*/
public void setPerspective(float fov, float aspect, float near) {
// X方向的缩放比
float zoomX = 1f / (float)Math.tan(fov * 0.5f);
// Y方向的缩放比
float zoomY = zoomX * aspect;
float m00 = zoomX, m01 = 0, m02 = 0, m03 = 0;
float m10 = 0, m11 = zoomY, m12 = 0, m13 = 0;
float m20 = 0, m21 = 0, m22 = -1, m23 = -2 * near;
float m30 = 0, m31 = 0, m32 = -1, m33 = 0;
projectionMatrix.set(m00, m01, m02, m03, m10, m11, m12, m13, m20, m21, m22, m23, m30, m31, m32, m33);
}
正交投影
三维观察不止透视投影一种,还有正交(Orthographic)投影、斜投影、等轴投影等。通过正交投影观察3D物体,画面很像是2D平面。
这种投影只是顺带着实现,不想介绍太多。
/**
* 正交投影
* @param left
* @param right
* @param bottom
* @param top
* @param near
* @param far
*/
public void setOrthographic(float left, float right, float bottom, float top, float near, float far) {
float w = right - left;
float h = top - bottom;
float depth = far - near;
// 计算矩阵
float m00 = 2 / w, m01 = 0, m02 = 0, m03 = -(right + left)/w;
float m10 = 0, m11 = 2 / h, m12 = 0, m13 = -(top + bottom)/h;
float m20 = 0, m21 = 0, m22 = -2/depth, m23 = -(far + near)/depth;
float m30 = 0, m31 = 0, m32 = 0, m33 = 1;
projectionMatrix.set(m00, m01, m02, m03, m10, m11, m12, m13, m20, m21, m22, m23, m30, m31, m32, m33);
}
视口变换矩阵
视口变换也称为屏幕空间变换,目的是把通过Model-View-Projection变换的物体,正确绘制到屏幕上。由于经过变换后物体的x、y坐标范围都是[-1, 1],需要通过放大才能画到宽为width、高为height的屏幕上。
下面这个矩阵,对齐次坐标做了2个变换。其一是把原点平移到屏幕中心(width/2, height/2, 0),见矩阵的第四列;其二是水平放大 width/2 倍,垂直放大 height/2倍,Z坐标不变,见矩阵左上角3x3矩阵的对角线元素。
/**
* 视口变换矩阵
*/
public void updateViewportMatrix() {
float w = width * 0.5f;
float h = height * 0.5f;
// 把模型移到屏幕中心,并且按屏幕比例放大。
float m00 = w, m01 = 0, m02 = 0, m03 = w;
float m10 = 0, m11 = -h, m12 = 0, m13 = h;
float m20 = 0, m21 = 0, m22 = 1f, m23 = 0;
float m30 = 0, m31 = 0, m32 = 0, m33 = 1;
viewportMatrix.set(m00, m01, m02, m03, m10, m11, m12, m13, m20, m21, m22, m23, m30, m31, m32, m33);
}
绘制3D网格
完成这些变换矩阵的计算后,终于可以开始绘制3D网格了。我在Mesh类中增加了一个方法,用来绘制三角网格。
/**
* 渲染3D场景
* @param imageRaster
* @param camera
*/
public void render(ImageRaster imageRaster, Camera camera) {
// 世界变换矩阵
Matrix4f worldMat = transform.toTransformMatrix();
// 观察-投影变换矩阵
Matrix4f viewProjectionMat = camera.getViewProjectionMatrix();
// 模型-观察-投影变换矩阵
Matrix4f mvp = viewProjectionMat.mult(worldMat);
// 视口变换矩阵
Matrix4f viewportMatrix = camera.getViewportMatrix();
// 用于保存变换后的向量坐标。
Vector4f v1 = new Vector4f();
Vector4f v2 = new Vector4f();
Vector4f v3 = new Vector4f();
// 遍历所有三角形
for (int i = 0; i < indexes.length - 2; i += 3) {
int a = indexes[i];
int b = indexes[i + 1];
int c = indexes[i + 2];
Vector3f va = positions[a];
Vector3f vb = positions[b];
Vector3f vc = positions[c];
// 使用齐次坐标计算顶点。
v1.set(va.x, va.y, va.z, 1);
v2.set(vb.x, vb.y, vb.z, 1);
v3.set(vc.x, vc.y, vc.z, 1);
// 模型-观察-透视 变换
mvp.mult(v1, v1);
mvp.mult(v2, v2);
mvp.mult(v3, v3);
// 透视除法
v1.multLocal(1f/v1.w);
v2.multLocal(1f/v2.w);
v3.multLocal(1f/v3.w);
// 把顶点位置修正到屏幕空间。
viewportMatrix.mult(v1, v1);
viewportMatrix.mult(v2, v2);
viewportMatrix.mult(v3, v3);
// 画三角形
imageRaster.drawTriangle((int)v1.x, (int)v1.y, (int)v2.x, (int)v2.y, (int)v3.x, (int)v3.y, ColorRGBA.WHITE);
}
}
为了兼容以前的代码,我在Application类中增加了一个新的渲染队列,用来保存所有的3D网格模型,并且在start方法中初始化了摄像机。
/**
* 应用程序主类
*
* @author yanmaoyuan
*
*/
public abstract class Application {
// 前略..
// 摄像机
private Camera camera;
// 渲染队列
protected List<Mesh> meshes;
/**
* 启动程序
*/
public void start() {
// ..
// 创建摄像机
camera = new Camera(width, height);
// ..
}
}
然后重写了render方法,增加了绘制3D网格的代码。
/**
* 绘制画面
*/
protected void render(float delta) {
// 清空场景
renderer.clear();
// 绘制场景
int len = meshes.size();
if (len > 0) {
for(int i=0; i < len; i++) {
meshes.get(i).render(renderer.getImageRaster(), camera);
}
}
// 绘制2D场景
len = scene.size();
if (len > 0) {
for (int i = 0; i < len; i++) {
scene.get(i).draw(renderer.getImageRaster());
}
}
// 交换画布缓冲区,显示画面
screen.swapBuffer(renderer.getRenderContext(), framePerSecond);
}
测试用例
写个测试用例,在屏幕上显示一个旋转的小立方体。
package net.jmecn.examples;
import net.jmecn.Application;
import net.jmecn.math.Vector3f;
import net.jmecn.renderer.Camera;
import net.jmecn.scene.Mesh;
/**
* 测试3D观察的效果
* @author yanmaoyuan
*
*/
public class Test3DView extends Application {
public static void main(String[] args) {
Test3DView app = new Test3DView();
app.setResolution(400, 300);
app.setTitle("3D View");
app.setFrameRate(60);
app.start();
}
private Mesh mesh;// 网格
private final static float PI = 3.1415626f;
private final static float _2PI = PI * 2;
private float angle = 0;// 旋转角度
@Override
protected void initialize() {
// 立方体
Vector3f[] positions = {
new Vector3f(-1, -1, -1),
new Vector3f(1, -1, -1),
new Vector3f(-1, 1, -1),
new Vector3f(1, 1, -1),
new Vector3f(-1, -1, 1),
new Vector3f(1, -1, 1),
new Vector3f(-1, 1, 1),
new Vector3f(1, 1, 1),
};
// 定义六面共12个三角形
int[] indexes = {
0, 2, 1, 1, 2, 3, // back
4, 5, 7, 4, 7, 6, // front
5, 1, 3, 5, 3, 7, // left
0, 6, 2, 0, 4, 6, // right
2, 6, 7, 2, 7, 3, // top
1, 4, 0, 1, 5, 4 // bottom
};
mesh = new Mesh(positions, indexes);
// 添加到场景中
meshes.add(mesh);
// 调整摄像机的位置
Camera cam = getCamera();
cam.setLocation(new Vector3f(3, 4, 8));
cam.setDirection(new Vector3f(-3, -4, -8).normalizeLocal());
cam.updateViewProjectionMatrix();
}
@Override
protected void update(float delta) {
// 每秒旋转180°
angle += delta * PI;
// 若已经旋转360°,则减去360°。
if (angle > _2PI) {
angle -= _2PI;
}
// 计算旋转:绕Z轴顺时针方向旋转
mesh.getTransform().getRotation().fromAxisAngle(Vector3f.UNIT_Y, -angle);
}
}
运行结果:
总结
这部分的功能,纯粹是各种数学运算,需要恶补线性代数。