Java软光栅渲染器-三维观察

目标

  • 定义虚拟摄像机
  • 计算观察变换矩阵
  • 计算透视投影变换矩阵
  • 计算视口变换矩阵
  • 显示一个立方体的线框模型

本章相关源码:

实现

这一部分的实现有点复杂,主要是各种变换矩阵的演算。我周五晚上第一次实现这个功能的时候,照抄jME3的代码,但是结果并不让人满意。

后来我感觉不弄明白计算方法是行不通的。于是花了两天时间学习各种资料,并在纸上推算了一遍,周日才把程序改对了。错误的原因,仅仅是写错了一个数的正负号,以及在投影矩阵中用 height/width 的值代替了 width/height 的值。修正后画面是这样的:

这部分的数学推算,主要参考了这些资料:

在这个基础上,很快又实现了背面消隐。

具体的实现过程如下。

定义摄像机

这一节主要是说明 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);
    }

}

运行结果:

总结

这部分的功能,纯粹是各种数学运算,需要恶补线性代数。