Java软光栅渲染器-3D图形渲染管线
目标
3D图形渲染管线,或者简称渲染管线,网上有非常非常多的资料,凡是与3D图形有关的书籍也会专门介绍。下面是一些参考资料:
- 《3D数学技术:图形与游戏开发》第15.1节“图形管道概述”。
- 《Real Time Rendering》第二章 图形渲染管线
- 浅谈 GPU图形固定渲染管线
- 3D图形渲染管线
- 可编程渲染管线比固定管线的优势在哪?有什么应用?
“Java软光栅渲染器”开发到现在,我一边翻书恶补图形学知识,一边尝试动手实现书上的各种算法,差不多理解渲染管线各阶段在做什么了。现在需要对知识进行梳理,并且把程序重构一下,使其能够比较清晰地展现出渲染管线的各个过程。
本文的目标:
- 复习图形渲染管线的概念
- 重构代码,建立渲染管线
- 实现场景图
本次重构的相关代码:
场景图部分
渲染管线部分
实现
问题分析
在我前面的代码中,绘制3D图形的代码都是直接写在Mesh类中的,Renderer完全是个摆设。
当时延续的是2D光栅化的思路,即在几何类中实现Drawable接口,然后把imageRaster传给它,让它把自己画在Image中。
再后来实现三维观察,需要通过Camera来获得观察变换矩阵和投影变换矩阵。我又在Mesh类中增加了render方法,并把imageRaster和camera同时传给了mesh。
接下来,我还需要实现光栅化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()的调用;
- 其他..
总之这次重构之后,我觉得这个程序越来越像回事了。