Java软光栅渲染器-网格数据结构

目标

  • 定义3D网格的数据结构
  • 使用世界坐标变换,绘制网格。

实现

网格

前面已经基本完成了3D数学库,现在来定义3D模型的数据结构。

package net.jmecn.scene;

import net.jmecn.geom.Drawable;
import net.jmecn.math.ColorRGBA;
import net.jmecn.math.Matrix4f;
import net.jmecn.math.Transform;
import net.jmecn.math.Vector3f;
import net.jmecn.renderer.ImageRaster;

/**
 * 定义三角形网格
 * @author yanmaoyuan
 *
 */
public class Mesh implements Drawable {
    /**
     * 顶点数组
     */
    private Vector3f[] positions;
    /**
     * 顶点索引
     */
    private int[] indexes;
    /**
     * 空间变换
     */
    private Transform transform;

    /**
     * 初始化三角网格。
     * 
     * @param vertexes
     * @param indexes
     */
    public Mesh(Vector3f[] positions, int[] indexes) {
        this.positions = positions;
        this.indexes = indexes;
        this.transform = new Transform();
    }

    public Transform getTransform() {
        return transform;
    }

    @Override
    public void draw(ImageRaster imageRaster) {
    }
}

这个定义很简单,Mesh类中保存了2个数组,分别是网格中顶点的位置数组,以及将它们组合成三角形的索引数组。

除此之外,还定义了一个Transform对象,用来表示模型在3D空间中的空间变换。由于目前不考虑复杂的场景,可以认为这个Transform就代表了模型的世界空间变换。

画图

实现 Drawable 接口,把这个网格画出来。

@Override
public void draw(ImageRaster imageRaster) {
    
    // 世界变换矩阵
    Matrix4f worldMat = transform.toTransformMatrix();

    // 用于保存变换后的向量坐标。
    Vector3f v1 = new Vector3f();
    Vector3f v2 = new Vector3f();
    Vector3f v3 = new Vector3f();
    
    // 遍历所有三角形
    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];
        
        // 世界空间变换
        worldMat.mult(va, v1);
        worldMat.mult(vb, v2);
        worldMat.mult(vc, v3);
        
        // 画三角形
        imageRaster.drawTriangle((int)v1.x, (int)v1.y, (int)v2.x, (int)v2.y, (int)v3.x, (int)v3.y, ColorRGBA.WHITE);
    }
}

如你所见,这个draw方法非常……简陋。它仅包含一个世界空间变换,用来把顶点坐标从 模型空间(Model Spcae) 转换到 世界空间(World Space)。再就是遍历网格中所有的三角形,然后用光栅器把三角形画出来。

测试用例

我等不及要写个测试用例来看看实际的效果了。在下面这段代码中,定义了一个简单的正方形。它的左下角位于 模型空间 的原点,边长为1。

package net.jmecn.examples;

import net.jmecn.Application;
import net.jmecn.math.Vector3f;
import net.jmecn.scene.Mesh;

/**
 * 测试世界变换矩阵
 * @author yanmaoyuan
 *
 */
public class Test3DWorldMatrix extends Application {

    public static void main(String[] args) {
        Test3DWorldMatrix app = new Test3DWorldMatrix();
        app.setResolution(400, 400);
        app.setTitle("3D WorldMatrix");
        app.setFrameRate(60);
        app.start();
    }

    private Mesh mesh;// 网格

    @Override
    protected void initialize() {
        // 一个四边形的顶点
        Vector3f[] positions = {
            new Vector3f(0, 0, 0),
            new Vector3f(1, 0, 0),
            new Vector3f(1, 1, 0),
            new Vector3f(0, 1, 0),
        };
        
        // 定义两个三角形
        int[] indexes = {
            0, 1, 2,
            0, 2, 3,
        };
        
        mesh = new Mesh(positions, indexes);
        
        // 初始化空间变换
        mesh.getTransform().setTranslation(200, 200, 0);
        mesh.getTransform().setScale(50);
        
        // 添加到场景中
        scene.add(mesh);
    }

    @Override
    protected void update(float delta) {
        // TODO 
    }

}

由于目前我没有实现摄像机,没有视口透视矩阵,因此场景中的一切看起来都是扁平的,而且长度单位与屏幕的单位一样,是像素。边长为1的正方形,在画面上看起来就只是一个像素点而已。

为了能够观察到这个正方形,我使用Transform来对它进行了比例变换,将其放大了50倍,这样就能在画面上看到一个边长为50像素的正方形了。我还使用了一个平移变换,把这个正方形的原点移动到了(200, 200, 0)处。

运行程序,可以看到这样的画面:

这个结果与我想象的有点不一样,但是符合实际情况。我定义正方形时使用的是OpenGL坐标系,原点在xoy平面的左下角,z轴指向我。而在Canvas中绘图时,xoy平面的原点在左上角,z轴指向屏幕深处。因此这个正方形看起来像是被垂直翻转了。

这没关系,可以用**屏幕空间矩阵(Screen Space Matrix)**来修正。

修正的工作以后再做,我觉得现在这个例子并不能体现世界变换的作用。得让程序更复杂一些,否则感觉我简直白写数学库了。

改进测试用例

我决定让这个正方形动起来。

我定义了一个angle变量来计算旋转角(弧度),然后在update方法中计算了这个正方形的旋转变换(旋转角度)和平移变换(位置)。

private final static float PI = 3.1415626f;
private final static float _2PI = PI * 2;
private float angle = 0;// 旋转角度

@Override
protected void update(float delta) {
    // 每秒旋转180°
    angle += delta * PI;
    
    // 若已经旋转360°,则减去360°。
    if (angle > _2PI) {
        angle -= _2PI;
    }
    
    // 计算位移:以(200, 200)为中心,半径100做圆周运动。
    float x = (float) (Math.cos(angle) * 100 + 200);
    float y = (float) (Math.sin(angle) * 100 + 200);
    mesh.getTransform().setTranslation(x, y, 0);
    
    // 计算旋转:绕Z轴顺时针方向旋转
    mesh.getTransform().getRotation().fromAxisAngle(Vector3f.UNIT_Z, -angle);
}

运行一下,看看效果:

WTF!?虽然看起来蛮酷炫的,但这并是我想要的东西。

这看起来是因为没有清理画面导致的。检查一下ImageRaster中的fill方法,发现了问题:if语句里面不应该用 “与” 判断,而应该用 “或” 。

public void fill(ColorRGBA color) {
    int length = width * height;
    for (int i = 0; i < length; i++) {
        int index = i * 4;

        // 使用一个判断,避免无谓的赋值。
        if (components[index] != color.r && components[index + 1] != color.g && components[index + 2] != color.b
                || components[index + 3] != color.a) {
            components[index] = color.r;
            components[index + 1] = color.g;
            components[index + 2] = color.b;
            components[index + 3] = color.a;
        }
    }
}

犯这种错真丢人。。赶紧改过来。

public void fill(ColorRGBA color) {
    int length = width * height;
    for (int i = 0; i < length; i++) {
        int index = i * 4;

        // 使用一个判断,避免无谓的赋值。
        if (components[index] != color.r || components[index + 1] != color.g || components[index + 2] != color.b
                || components[index + 3] != color.a) {
            components[index] = color.r;
            components[index + 1] = color.g;
            components[index + 2] = color.b;
            components[index + 3] = color.a;
        }
    }
}

重新运行一下:

总结

本章定义了一个最基本的网格数据结构,并且实现了世界坐标变换。

不过目前看来,这个3D世界太“平”了,下一节将正式引入摄像机,实现一系列空间变换了。