Java软光栅渲染器-3D图形渲染管线

目标

3D图形渲染管线,或者简称渲染管线,网上有非常非常多的资料,凡是与3D图形有关的书籍也会专门介绍。下面是一些参考资料:

“Java软光栅渲染器”开发到现在,我一边翻书恶补图形学知识,一边尝试动手实现书上的各种算法,差不多理解渲染管线各阶段在做什么了。现在需要对知识进行梳理,并且把程序重构一下,使其能够比较清晰地展现出渲染管线的各个过程。

本文的目标:

  • 复习图形渲染管线的概念
  • 重构代码,建立渲染管线
  • 实现场景图

本次重构的相关代码:

场景图部分

渲染管线部分

实现

问题分析

在我前面的代码中,绘制3D图形的代码都是直接写在Mesh类中的,Renderer完全是个摆设。

当时延续的是2D光栅化的思路,即在几何类中实现Drawable接口,然后把imageRaster传给它,让它把自己画在Image中。

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

再后来实现三维观察,需要通过Camera来获得观察变换矩阵和投影变换矩阵。我又在Mesh类中增加了render方法,并把imageRaster和camera同时传给了mesh。

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

接下来,我还需要实现光栅化3D三角形、纹理采样、深度测试、Alpha测试、颜色混合、顶点光照、视锥裁剪等功能,如果继续扩充Mesh类的话,它的代码将会越来越臃肿。而且,成型的代码恐怕并不能很好的表现出“渲染管线”的概念。

我希望在代码中更加地清晰实现“渲染管线”,也希望 Mesh 能够回归一个纯粹的三维数据载体,所以需要代码重构。重构之后,以前的一些测试用例将无法运行,也需要重构。

重构Mesh

先定义Vertex类来保存3D图形引擎中一般需要处理的顶点数据:

package net.jmecn.scene;

import net.jmecn.math.Vector2f;
import net.jmecn.math.Vector3f;
import net.jmecn.math.Vector4f;

/**
 * 顶点数据
 * @author yanmaoyuan
 *
 */
public class Vertex {

    public Vector3f position;  // 顶点位置
    public Vector3f normal;    // 顶点法线
    public Vector4f color;     // 顶点颜色
    public Vector2f texCoord;  // 纹理坐标

}

删除Mesh类中的Transform成员、render方法、draw方法、Drawable接口,使它成为一个纯粹的三维数据类,只负责保存顶点缓存(VertexBuffer)和索引缓存(IndexBuffer)。

package net.jmecn.scene;

import net.jmecn.math.Vector2f;
import net.jmecn.math.Vector3f;
import net.jmecn.math.Vector4f;

/**
 * 定义三角形网格
 * 
 * @author yanmaoyuan
 *
 */
public class Mesh {
    /**
     * 顶点数据
     */
    protected Vertex[] vertexes;
    /**
     * 顶点索引
     */
    protected int[] indexes;


    public Vertex[] getVertexes() {
        return vertexes;
    }
    
    public int[] getIndexes() {
        return indexes;
    }
}

当然,Mesh类中还有一些构造方法,用来初始化网格数据。由于篇幅的原因,我就不全部贴出来了,你可以直接看源码。

场景图

为了能够构建更复杂的场景,我将放弃List<Mesh>这种数据结构,改用场景图(Scene Graph)来管理场景中的3D物体。

场景图实际上是一种树形结构,由两种不同的节点组成。其一是Geometry,代表能够被渲染的3D物体;其二是Node,无法被渲染,但是可以包含子节点。Node的子节点可以是Node,也可以是Geometry。通过不同层次的Node和Geometry组合,就能够生成非常复杂的场景。

举个例子:这就像你坐在和谐号列车的“XX车厢YY号座位”,而火车正在以300km/h的速度从A地开往B地。这条轨道修建在地球表面,地球本身正在自转,同时也在绕太阳公转。太阳系则位于银河系边缘,正在绕着银河系中央的黑洞飞速运行。请告诉我你现在的位置。

运动、坐标、位置等概念都是相对的,只有在选定一个参考系之后,绝对位置才有意义。同理,世界空间变换也只有在选定一个最大的参考系之后才有意义。

在场景图中,每个 Geometry 和 Node 对象都有相对父节点的Transform,需要跟父节点的Transform合并后才能计算出世界变换矩阵

我将在Application中定义一个rootNode,用来保存场景中所有的3D物体,它就是我选定的“最大参考系”。

public class Appliation {
    // ..
    // 渲染队列
    protected List<Drawable> scene;
    // List<Mesh> meshes;// <--- 这个不要了
    // 3D场景
    protected Node rootNode; // <--- 这里变了
    // ..
}

Spatial

Spatial 的意思是“空间”,它是Node和Geometry的父类,保存了空间变换(Transform)、父子关系等信息。

package net.jmecn.scene;

import net.jmecn.math.Transform;

/**
 * 代表三维空间,是Geometry和Node的父类。
 * 
 * @author yanmaoyuan
 *
 */
public abstract class Spatial {

    // 父节点
    private Node parent;
    // 相对空间变换
    private Transform localTransform = new Transform();
    // 世界空间变换
    private Transform worldTransform = new Transform();
    
    /**
     * 获得相对空间变换
     * @return
     */
    public Transform getLocalTransform() {
        return localTransform;
    }

    /**
     * 获得世界空间变换
     * @return
     */
    public Transform getWorldTransform() {
        worldTransform.set(localTransform);
        if (parent != null) {
            // 合并父节点的空间变换
            worldTransform.combineWithParent(parent.getWorldTransform());
        }
        return worldTransform;
    }
    
    /**
     * 从父节点中移除自己
     */
    public void removeFromParent() {
        if (parent != null) {
            parent.detachChild(this);
        }
    }
    
    /**
     * 设置父节点
     * @param newParent
     */
    protected void setParent(Node newParent) {
        if (newParent == null) {
            removeFromParent();
        }
        this.parent = newParent;
    }
    
    /**
     * 获得父节点
     * @return
     */
    public Node getParent() {
        return parent;
    }
    
}

Geometry

创建Geometry类来保存物体的Mesh 和 Material,它表示需要渲染的基本物体,类似于我之前定义的Mesh。Geometry中不实现render方法,它只提供get/set方法,供渲染器访问数据。

package net.jmecn.scene;

import net.jmecn.material.Material;

/**
 * 3D几何物体类,它是被渲染的最基本单元。
 * 
 * @author yanmaoyuan
 *
 */
public class Geometry extends Spatial {

    private Mesh mesh;
    private Material material;
    
    public Geometry() {}
    
    public Geometry(Mesh mesh) {
        this.mesh = mesh;
    }
    
    public Geometry(Mesh mesh, Material material) {
        this.mesh = mesh;
        this.material = material;
    }
    
    public Mesh getMesh() {
        return mesh;
    }

    public void setMesh(Mesh mesh) {
        this.mesh = mesh;
    }

    public Material getMaterial() {
        return material;
    }
    
    public void setMaterial(Material mat) {
        this.material = mat;
    }
}

Material 代表物体的材质,用于渲染时的着色。由于目前还没有实现任何材质,Material中是空的。

package net.jmecn.material;

/**
 * 材质
 * 
 * @author yanmaoyuan
 *
 */
public class Material {
}

Node

Node中主要保存了子节点的列表,并提供添加、移除子节点的功能。getGeometryList()方法遍历了整个场景,用于获得场景中所有的Geometry对象。

package net.jmecn.scene;

import java.util.ArrayList;
import java.util.List;

/**
 * 节点
 * @author yanmaoyuan
 *
 */
public class Node extends Spatial {

    private List<Spatial> children;
    
    public Node() {
        children = new ArrayList<Spatial>();
    }
    
    /**
     * 添加子节点
     * @param spatial
     */
    public void attachChild(Spatial spatial) {
        children.add(spatial);
        spatial.setParent(this);
    }
    /**
     * 移除子节点
     * @param spatial
     */
    public void detachChild(Spatial spatial) {
        children.remove(spatial);
    }

    /**
     * 遍历场景,获取所有Geometry
     * @param list
     * @return
     */
    public List<Geometry> getGeometryList(List<Geometry> list) {
        if (list == null) {
            list = new ArrayList<Geometry>();
        }
        int len = children.size();
        for(int i=0; i<len; i++) {
            Spatial spatial = children.get(i);
            if (spatial instanceof Geometry) {
                list.add((Geometry) spatial);
            } else if (spatial instanceof Node) {
                // 递归
                Node node = (Node) spatial;
                node.getGeometryList(list);
            }
        }
        
        return list;
    }
}

重构渲染管线

下面的伪代码,来自《3D数学技术:图形与游戏开发》第15.1节“图形管道概述”。

// 首先,设置观察场景的方式
setupTheCamera();
// 清除z-buffer
clearZBuffer();
// 设置全局环境光源和雾化
setupGloableLightingAndFog();
// 得到可见物体列表
potentiallyVisiblaObjectList = highLevelVisibilityDetermination(scene);
// 渲染它们
for ( each object in potentiallyVisiblaObjectList) {
    // 使用包围体方法执行低级别VSD检测
    if (! object.isBoundingVolumeVisible() ) continue;
    // 提取或者渐进式生成几何体
    triMesh = object.getGeometry();
    // 裁剪和渲染面
    for (each triangle in the geometry) {
        // 变换顶点到裁剪空间,执行顶点级别光照
        clipSpaceTriangle = transformAndLighting(triangle);
        // 三角形为背向的?
        if (clipSpaceTriangle.isBackFacing()) continue;
        // 对视锥裁剪三角形
        clippedTriangle = clipToViewVolume(clipSpaceTriangle);
        if (clippedTriangle.isEmpty()) continu;
        // 三角形投影至屏幕空间,并且光栅化
        clippedTriangle.projectToScreenSpace();
        for (each pixel in the triangle) {
            // 插值颜色,z-缓冲值和纹理映射坐标
            // 执行zebuffering 和 alpha 检测
            if (!zbufferTest()) continue;
            if (!alphaTest()) continue;
            // 像素着色
            color = shadePixel();
            // 写内容到帧缓冲和z-缓冲
            writePixel(color, interpolatedZ);
        }
    }
}

我将参考这个代码来重构渲染管线。主要是重写应用程序(Application)、渲染器(Renderer)和光栅器(Raster)。

应用程序阶段

在Application中,需要重写render方法。先从rootNode中提取所有的Geometry,然后和Camera一起交给渲染器(renderer)去处理。

// 3D场景
protected Node rootNode; // <--- 这里变了

/**
 * 绘制画面
 */
protected void render(float delta) {
    // 清空场景
    renderer.clear();

    // 获取所有物体,绘制3D场景
    List<Geometry> geomList = rootNode.getGeometryList(null);
    renderer.render(geomList, camera);// <-- 这里变了
    
    // 绘制2D场景..
    
    // 交换画布缓冲区,显示画面..
}

顶点着色阶段

顶点着色阶段主要在Renderer类中完成。这个类被我动了大手术,原来Mesh中绘制3D物体的代码都被移到了Renderer中。

各种空间变换矩阵的计算都是在render方法中计算的,主要用于顶点处理变换。

为了使思路更清晰,我省略了很多代码:

private Matrix4f worldMatrix = new Matrix4f();
private Matrix4f viewMatrix = new Matrix4f();
private Matrix4f projectionMatrix = new Matrix4f();
private Matrix4f viewProjectionMatrix = new Matrix4f();

private Matrix4f worldViewMatrix = new Matrix4f();
private Matrix4f worldViewProjectionMatrix = new Matrix4f();

private Matrix4f viewportMatrix = new Matrix4f();

/**
 * 渲染场景
 * @param scene
 * @param camera
 */
public void render(List<Geometry> geomList, Camera camera) {
    
    // 根据Camera初始化观察变换矩阵。
    viewMatrix.set(camera.getViewMatrix());
    projectionMatrix.set(camera.getProjectionMatrix());
    viewProjectionMatrix.set(camera.getViewProjectionMatrix());

    // TODO 剔除那些不可见的物体
    
    // 遍历场景中的Mesh
    for(int i=0; i<geomList.size(); i++) {
        Geometry geom = geomList.get(i);
        
        // 根据物体的世界变换,计算MVP等变换矩阵。
        worldMatrix.set(geom.getWorldTransform().toTransformMatrix());
        viewMatrix.mult(worldMatrix, worldViewMatrix);
        viewProjectionMatrix.mult(worldMatrix, worldViewProjectionMatrix);
        
        // TODO 使用包围体,剔除不可见物体
        
        // 渲染
        render(geom);
    }
}

上面这个方法主要负责进行各种矩阵计算,并进行Geometry层次的剔除,然后调用下面这个方法来渲染单个物体。

在这一步中,需要把Geometry中的材质等渲染参数缓存到Rednerer中,因为在顶点着色以及光栅化时会用到这些数据。

private Material material;

/**
 * 渲染单个物体
 * @param geometry
 */
protected void render(Geometry geometry) {
    // 提取物体的材质等渲染参数
    material = geometry.getMaterial();

    // 提取网格数据
    Mesh mesh = geometry.getMesh();
    int[] indexes = mesh.getIndexes();
    Vertex[] vertexes = mesh.getVertexes();
    
    // 遍历所有三角形
    for (int i = 0; i < indexes.length; i += 3) {

        Vertex v0 = vertexes[indexes[i]];
        Vertex v1 = vertexes[indexes[i+1]];
        Vertex v2 = vertexes[indexes[i+2]];
        
        // 执行顶点着色器
        RasterizationVertex out0 = vertexShader(v0);
        RasterizationVertex out1 = vertexShader(v1);
        RasterizationVertex out2 = vertexShader(v2);
        
        // 在观察空间进行背面消隐
       
        if (cullBackFace(a, b, c))
            continue;

        // TODO 视锥裁剪
        
        // TODO 将顶点转换到裁剪空间
        
        // 光栅化三角形
        raster.rasterizeTriangle(out0, out1, out2);
    }
}

render方法调用了vertexShader,主要用于顶点的空间变换和着色。

/**
 * 顶点着色
 * @param vert
 * @return
 */
protected RasterizationVertex vertexShader(Vertex vert) {
    RasterizationVertex out = new RasterizationVertex();
    // 顶点位置
    out.position.set(vert.position, 1f);
    // 顶点法线
    if (vert.normal != null) {
        out.normal.set(vert.normal);
        out.hasNormal = true;
    }
    // 纹理坐标
    if (vert.texCoord != null) {
        out.texCoord.set(vert.texCoord);
        out.hasTexCoord = true;
    }
    // 顶点颜色
    if (vert.color != null) {
        out.color.set(vert.color);
        out.hasVertexColor = true;
    }
   
    // 顶点着色器
    // 模型-观察-透视 变换
    worldViewProjectionMatrix.mult(out.position, out.position);
    
    return out;
}

注意:绘制3D物体时,需要对顶点数据进行空间变换和插值。在进行光栅化时,顶点数据应该使用四维齐次坐标,而不是原本的Vector3f。这里需要另一个对象来代替Vertex,表示光栅化阶段需要的数据。

package net.jmecn.scene;

import net.jmecn.math.Vector2f;
import net.jmecn.math.Vector3f;
import net.jmecn.math.Vector4f;

/**
 * 准备进入光栅化阶段的顶点数据
 * 
 * @author yanmaoyuan
 *
 */
public class RasterizationVertex {

    public Vector4f position = new Vector4f();  // 片段位置
    public Vector4f color = new Vector4f(1);    // 片段颜色
    public Vector3f normal = new Vector3f();    // 片段法线
    public Vector2f texCoord = new Vector2f();  // 纹理坐标

    public float w;// 透视除法之前的w坐标
    
    public boolean hasNormal = false;
    public boolean hasTexCoord = false;
    public boolean hasVertexColor = false;
    
    /**
     * 线性插值
     * @param v0
     * @param v1
     * @param t
     * @return
     */
    public RasterizationVertex interpolateLocal(RasterizationVertex v0, RasterizationVertex v1, float t) {
        // 顶点插值
        position.interpolateLocal(v0.position, v1.position, t);
        w = (1 - t) * v0.w + t * v1.w;

        // 法线插值
        if (v0.hasNormal) {
            normal.interpolateLocal(v0.normal, v1.normal, t);
            this.hasNormal = v0.hasNormal;
        }
        // 颜色插值
        if (v0.hasVertexColor) {
            color.interpolateLocal(v0.color, v1.color, t);
            this.hasVertexColor = v0.hasVertexColor;
        }
        // 纹理插值
        if (v0.hasTexCoord) {
            texCoord.interpolateLocal(v0.texCoord, v1.texCoord, t);
            this.hasTexCoord = v0.hasTexCoord;
        }
        
        return this;
    }

    /**
     * 透视除法
     */
    public void perspectiveDivide() {
        // 保存w值,用于透视修正
        this.w = position.w;
        // 齐次坐标
        position.multLocal(1f / position.w);
    }
}

至于背面剔除,前一章已经实现了,现在只是把代码搬到Renderer中来。

/**
 * 剔除背面
 * 
 * @param a
 * @param b
 * @param c
 * @return
 */
protected boolean cullBackFace(Vector3f a, Vector3f b, Vector3f c) {

    // TODO 计算表面法线,判断是否为背面
    return false;
}

光栅化阶段

在3D光栅化阶段,原来的ImageRaster已经不适用了。现在需要光栅化的数据变成了由顶点组成的三角形,每个顶点都有位置、法线、顶点颜色、纹理坐标等信息,光栅化的算法有些变化,而且还需要深度缓冲(z-buffer或depthbuffer)来决定是否覆盖像素。

为了兼容之前的2D测试用例,我决定继承ImageRaster,在其子类中扩充实现3D光栅化的算法。我把这个子类命名为SoftwareRaster。

注意:为了在SoftwareRaster中能直接访问ImageRaster中的成员,需要把ImageRaster中的成员改为protected权限。

SoftwareRaster的结构如下:

package net.jmecn.renderer;

import net.jmecn.math.Vector4f;
import net.jmecn.scene.RasterizationVertex;

/**
 * 软件光栅器
 * @author yanmaoyuan
 *
 */
public class SoftwareRaster extends ImageRaster {       
    // 深度缓冲
    protected float[] depthBuffer;

    // 渲染器
    protected Renderer renderer;

    public SoftwareRaster(Renderer renderer, Image image) {
        super(image);
        this.depthBuffer = new float[width * height];
        this.renderer = renderer;
    }
    
    /**
     * 清除深度缓冲
     */
    public void clearDepthBuffer() {
        int length = width * height;
        for(int i=0; i<length; i++) {
            depthBuffer[i] = 1.0f;
        }
    }

    /**
     * 片段着色器
     * @param frag
     */
    private void fragmentShader(RasterizationVertex frag) {}

    /**
     * 光栅化点
     * @param x
     * @param y
     * @param frag
     */
    public void rasterizePixel(int x, int y, RasterizationVertex frag) {
        
        if (x < 0 || y < 0 || x >= width || y >= height) {
            return;
        }
       
        // 执行片段着色器
        fragmentShader(frag);
        
        // TODO 深度测试
        
        // TODO Alpha测试

        // TODO 颜色混色
        
        // TODO 写入depthBuffer
        
        // TODO 写入frameBuffer
    }
    
    /**
     * 光栅化线段,使用Bresenham算法。
     * @param v0
     * @param v1
     */
    public void rasterizeLine(RasterizationVertex v0, RasterizationVertex v1) { }

    /**
     * 光栅化扫描线
     * @param v0
     * @param v1
     * @param y
     */
    public void rasterizeScanline(RasterizationVertex v0, RasterizationVertex v1, int y) { }

    /**
     * 画平底实心三角形
     * @param v0 上顶点
     * @param v1 底边左顶点
     * @param v2 底边右顶点
     */
    private void fillBottomLineTriangle(RasterizationVertex v0, RasterizationVertex v1,
            RasterizationVertex v2) { }

    /**
     * 画平顶实心三角形
     * @param v0 顶边左顶点
     * @param v1 顶边右顶点
     * @param v2 下顶点
     */
    private void fillTopLineTriangle(RasterizationVertex v0, RasterizationVertex v1,
            RasterizationVertex v2) { }

    /**
     * 光栅化三角形
     * @param v0
     * @param v1
     * @param v2
     */
    public void rasterizeTriangle(RasterizationVertex v0, RasterizationVertex v1,
            RasterizationVertex v2) { }

    /**
     * 深度测试
     * @param oldDepth
     * @param newDepth
     * @return
     */
    private boolean depthTest(float oldDepth, float newDepth) { return false; }

    /**
     * 对齐
     * @param v
     * @param min
     * @param max
     * @return
     */
    float clamp(float v, float min, float max) {
        if (min > max) {
            float tmp = max;
            max = min;
            min = tmp;
        }
        
        if (v < min)
            v = min;
        if (v > max)
            v = max;
        return v;
    }

    private final static float INV_SCALE = 1f / 255f;
    /**
     * 提取颜色
     * @param x
     * @param y
     * @return
     */
    public Vector4f getColor(int x, int y) {
        Vector4f color = new Vector4f();
        
        int index = (x + y * width) * 4;
        float r = (float)(0xFF & components[index]) * INV_SCALE;
        float g = (float)(0xFF & components[index+1]) * INV_SCALE;
        float b = (float)(0xFF & components[index+2]) * INV_SCALE;
        float a = (float)(0xFF & components[index+3]) * INV_SCALE;
        
        color.set(r, g, b, a);
        return color;
    }
}

可以看到,这个类的主要作用跟ImageRaster非常相似,也是光栅化点、线端、三角形。光栅化三角形时,也使用了扫描线算法,分上下两个三角形别分光栅化。主要区别是,增加了fragmentShader方法,用于片段着色;还加了depthBuffer,用于将来实现深度测试。

SoftwareRaster在构造时,除了和ImageRaster一样需要Image对象,还需要一个Renderer对象。因为在光栅化时可能会用到Renderer中缓存的数据,我需要保存一个对它的引用。

/**
 * 渲染器
 * @author yanmaoyuan
 *
 */
public class Renderer {

    // 渲染图像
    private Image image;
    // 光栅器
    private SoftwareRaster raster;
    
    /**
     * 初始化渲染器
     * @param width
     * @param height
     */
    public Renderer(int width, int height) {
        image = new Image(width, height);
        raster = new SoftwareRaster(this, image);
        // 计算视口变换矩阵
        updateViewportMatrix(width, height);
    }
    // ..
}

我还把drawLine、drawTriangle这种方法名改成了rasterizeLine、rasterizeTriangle,因为这样感觉更加高大上。:)

总结

这次重构还有很多细节,并没有全部写在本文中,因为我只想突出“渲染管线”的实现。

  • viewportMatrix的计算从Camera类改到了Renderer类;
  • Applicatio中,renderer的backgroundColor从BLACK改成了DARKGRAY;
  • Render的clear方法中,增加了对raster.clearDepthBuffer()的调用;
  • 其他..

总之这次重构之后,我觉得这个程序越来越像回事了。