全球速讯:了解3D世界的黑魔法-纯Java构造一个简单的3D渲染引擎
2022-09-14 10:08:16来源:阿里开发者
作者 | 李历成(徜葆)
前言当今用于游戏和多媒体的3D渲染引擎在数学和编程的复杂性上足以令大多数人望而生畏,从编程接口的OpenGL再到逼真到令人叹为观止的UE5(虚幻五)引擎,后者单单引擎本身(不含调试)的大小就达到了将近40g(当然UE5不光只有渲染的功能),其中带来的全新的核心的Nanite虚拟微多边形几何技术和Lumen动态全局光照技术更是及其复杂。
对于非渲染引擎相关工作的开发者来说,可能认为即使构建最简单的3D程序也非常困难,但事实上并非如此,本篇文章将通过简单的200多行的纯 Java代码,去实践正交投影、简单三角形光栅化、z缓冲(深度缓冲区)和平面着色等基本的3D渲染技术,然后在下一片文章中,将着重介绍光线追踪的知识。
【资料图】
当然本篇文章最终实现的“3D渲染引擎”非常简单,没有做任何的算法优化,而且仅使用到了CPU,实际性能远不如OpenGl。不过其目的是用于去帮我们了解真正的现代引擎是如何发挥它们的黑魔法,以便更好的上手使用它们。
需要的知识储备三角函数、矩阵运算、向量运算、法向量。
如果你尚未学习或者忘记了以上的知识也不用担心,本篇文章中会结合例子对上述知识进行简单的解释,同时也不必太过纠结这些数学知识,会用即可,毕竟连卡神也会“what the fuck?”。
当然如果熟悉上述知识,阅读起来会更加轻松。
目标我们将会绘制一个四面体,因为它是最简单的3D图形~
界面用于展示图形的界面
public static void main(String[] args) { JFrame frame = new JFrame(); Container pane = frame.getContentPane(); pane.setLayout(new BorderLayout()); // panel to display render results JPanel renderPanel = new JPanel() { public void paintComponent(Graphics g) { Graphics2D g2 = (Graphics2D) g; g2.setColor(Color.BLACK); g2.fillRect(0, 0, getWidth(), getHeight()); // rendering magic will happen here } }; pane.add(renderPanel, BorderLayout.CENTER); frame.setSize(600, 600); frame.setVisible(true); }基础坐标系点与平面
现在让我们添加一些3D世界的基本的模型类——顶点和三角形。Vertex 只是一个简单的结构来存储我们的三个坐标(X、Y 和 Z),而三角形将三个顶点绑定在一起并存储它的颜色。
// X 坐标表示左右方向的移动// Y 表示屏幕上的上下移动// Z 表示深度(因此 Z 轴垂直于您的屏幕)。正 Z 表示“朝向观察者”。class Vertex { double x; double y; double z; Vertex(double x, double y, double z) { this.x = x; this.y = y; this.z = z; }}class Triangle { Vertex v1; Vertex v2; Vertex v3; Color color; Triangle(Vertex v1, Vertex v2, Vertex v3, Color color) { this.v1 = v1; this.v2 = v2; this.v3 = v3; this.color = color; }}
那么为什么要使用三角形来描述3D世界呢?
a.三角形是最简单的多边形,少于3个顶点就不能成为一个表面b.三角形必然是平坦的c.三角形经多种转换之后,仍然是三角形,这对于仿射转换和透视转换也成立。最坏的情况下,从三角形的边去看,三角形会退化为线段。在其它角度观察,仍能维持是三角形d.它可以很好地用叉积判断一个点是不是在三角形内部(三角形的内外定义特别清晰)
e.几乎所有商用图形加速硬件都是为三角形光栅化而设计的
构造目标三维图形非常简单,就是四个三角形合并而成(先将它们放入列表)。同时为了区分它们,赋予不同的颜色。
List tris = new ArrayList<>();tris.add(new Triangle(new Vertex(100, 100, 100), new Vertex(-100, -100, 100), new Vertex(-100, 100, -100), Color.WHITE));tris.add(new Triangle(new Vertex(100, 100, 100), new Vertex(-100, -100, 100), new Vertex(100, -100, -100), Color.RED));tris.add(new Triangle(new Vertex(-100, 100, -100), new Vertex(100, -100, -100), new Vertex(100, 100, 100), Color.GREEN));tris.add(new Triangle(new Vertex(-100, 100, -100), new Vertex(100, -100, -100), new Vertex(-100, -100, 100), Color.BLUE));
现在将它们放置到我们之前的界面中,不过先只展示框线。因为是正交投影,所以非常简单,忽略z轴绘制连线即可。
框线仅是用于目前直观的看到四面体,最终渲染的时候不会用到此2dAPI
// 生成的形状以原点 (0, 0, 0) 为中心,稍后我们将围绕该点进行旋转。g2.translate(getWidth() / 2, getHeight() / 2);g2.setColor(Color.WHITE);for (Triangle t : tris) { Path2D path = new Path2D.Double(); path.moveTo(t.v1.x, t.v1.y); path.lineTo(t.v2.x, t.v2.y); path.lineTo(t.v3.x, t.v3.y); path.closePath(); g2.draw(path);}
我们将得到如下结果:
这就是我们的四面体,为了让你相信,我们来为其添加一些旋转。
旋转处理 3d 点的方法有很多,但最灵活的是使用矩阵乘法。将点表示为 3x1 向量,然后转换就是简单地乘以 3x3 矩阵。
例如两倍缩放:
当然,本次重点讲解的是旋转,3D 空间中的任何旋转都可以表示为 3 种原始旋转的组合:XY 平面旋转、YZ 平面旋转和 XZ 平面旋转。我们可以为每个旋转写出变换矩阵,如下所示:
同时矩阵变换还有这样的特性:
即多次矩阵变换可以预先先合并为一个。
看看通过代码如何实现矩阵和矩阵的乘法:
class Matrix3 { double[] values; Matrix3(double[] values) { this.values = values; } Matrix3 multiply(Matrix3 other) { double[] result = new double[9]; for (int row = 0; row < 3; row++) { for (int col = 0; col < 3; col++) { for (int i = 0; i < 3; i++) { result[row * 3 + col] += this.values[row * 3 + i] * other.values[i * 3 + col]; } } } return new Matrix3(result); } Vertex transform(Vertex in) { return new Vertex( in.x * values[0] + in.y * values[3] + in.z * values[6], in.x * values[1] + in.y * values[4] + in.z * values[7], in.x * values[2] + in.y * values[5] + in.z * values[8] ); }}
构建XZ平面(以Y为轴左右)旋转和YZ平面(以X为轴上下)旋转。
double heading = Math.toRadians(x[0]); Matrix3 headingTransform = new Matrix3(new double[]{ Math.cos(heading), 0, -Math.sin(heading), 0, 1, 0, Math.sin(heading), 0, Math.cos(heading) });double pitch = Math.toRadians(y[0]); Matrix3 pitchTransform = new Matrix3(new double[]{ 1, 0, 0, 0, Math.cos(pitch), Math.sin(pitch), 0, -Math.sin(pitch), Math.cos(pitch) })//提前进行矩阵合并Matrix3 transform = headingTransform.multiply(pitchTransform);
然后通过监听鼠标的拖拽,改变x和y所代表的角度。
renderPanel.addMouseMotionListener(new MouseMotionListener() { @Override public void mouseDragged(MouseEvent e) { double yi = 180.0 / renderPanel.getHeight(); double xi = 180.0 / renderPanel.getWidth(); x[0] = (int) (e.getX() * xi); y[0] = -(int) (e.getY() * yi); renderPanel.repaint(); } @Override public void mouseMoved(MouseEvent e) { } });
现在我们可以讲之前的四面体旋转起来了;
g2.translate(getWidth() / 2, getHeight() / 2);g2.setColor(Color.WHITE);for (Triangle t : tris) { Vertex v1 = transform.transform(t.v1); Vertex v2 = transform.transform(t.v2); Vertex v3 = transform.transform(t.v3); Path2D path = new Path2D.Double(); path.moveTo(v1.x, v1.y); path.lineTo(v2.x, v2.y); path.lineTo(v3.x, v3.y); path.closePath(); g2.draw(path);}
效果:
光栅化现在我们需要开始用一些物质填充这些三角形。为此,我们首先需要对三角形进行“光栅化”——将其转换为屏幕上它所占据的像素列表。
光栅化(Rasterization)这一词在计算机图形学中经常出现,很多相关书籍都给出了自己的定义。不过我看目前一个比较准确的定义是:光栅化就是把东西画在屏幕上的一个过程(Rasterize == drawing onto the screen )文艺版解释:凝固生命的光栅化
光栅化中最重要的一个概念,判断一个像素与三角形之间的关系,更确却的来说我们考虑像素的中心点与三角形的位置关系。
判断一个点是否在三角形内在数学上有很多方法,本篇文章选择了叉积的方法(因为是正交投影,这样比较简单)对其他方法感兴趣的,可以根据其数学原理自己去实现一下:3D数学 | 判断点是否在三角形内
叉积叉积的方向与两个初始向量正交,这个方向我们可以由右手螺旋定则确定。我们可以伸出右手作a向量到b向量的叉积我们可以发现叉出的方向是正朝上的(图一),而用右手螺旋定则b向量到a向量的叉积叉出的方向是正朝下的,这就是为什么a x b=-b x a。
向量的叉乘公式:
(x1,y1,z1)X(x2,y2,z2)=(y1z2-y2z1, z1x2-z2y1, x1y2-x2y1)
之前也提到了,我们可以通过叉积去判断一个点是否在三角形内,举个例子(图2):
图1
图2
三角形的方向是逆时针的,从向量AB叉到向量AP叉出来的方向是-z,说明P点在AB的左侧;从向量BC叉到向量BP叉出来的方向是- z,说明P点在BC的左侧;从向量CA叉到向量CP叉出来的方向是-z,说明P点在AC的左侧,这就说明P点在三角形的内部。因为如果不在的话那么至少存在一条边使得P点在右侧(三角形是顺时针也没有问题,P点都在三角形的右边,我们只要保证P点一直在三条边的左边或者右边就可以说它在三角形的内部)。
这里注意,因为是正交投影,所以我们只考虑在投影平面(xy面)上的像素点是否在空间三角形在该面上的投影三角形内即可,即z可视为0。
代码:
static boolean sameSide(Vertex A, Vertex B, Vertex C, Vertex p){ Vertex V1V2 = new Vertex(B.x - A.x,B.y - A.y,B.z - A.z); Vertex V1V3 = new Vertex(C.x - A.x,C.y - A.y,C.z - A.z); Vertex V1P = new Vertex(p.x - A.x,p.y - A.y,p.z - A.z); //V1V2向量与V1V3的叉积如果和V1V2向量与V1p的叉积相同则在同一侧。 //只用判断z的方向 double V1V2CrossV1V3 = V1V2.x * V1V3.y - V1V3.x * V1V2.y; double V1V2CrossP = V1V2.x * V1P.y - V1P.x * V1V2.y; return V1V2CrossV1V3 * V1V2CrossP >= 0; }实现
现在我们可以知道一个点像素是否需要进行渲染了,现在要做的就是遍历范围内所有的像素点,判断它们是否需要进行渲染。
补全我们的代码:
for (Triangle t : tris) { Vertex v1 = transform.transform(t.v1); Vertex v2 = transform.transform(t.v2); Vertex v3 = transform.transform(t.v3); v1.x += getWidth() / 2.0; v1.y += getHeight() / 2.0; v2.x += getWidth() / 2.0; v2.y += getHeight() / 2.0; v3.x += getWidth() / 2.0; v3.y += getHeight() / 2.0; // 计算需要处理的范围 int minX = (int) Math.max(0, Math.ceil(Math.min(v1.x, Math.min(v2.x, v3.x)))); int maxX = (int) Math.min(img.getWidth() - 1, Math.floor(Math.max(v1.x, Math.max(v2.x, v3.x)))); int minY = (int) Math.max(0, Math.ceil(Math.min(v1.y, Math.min(v2.y, v3.y)))); int maxY = (int) Math.min(img.getHeight() - 1, Math.floor(Math.max(v1.y, Math.max(v2.y, v3.y)))); for (int y = minY; y <= maxY; y++) { for (int x = minX; x <= maxX; x++) { Vertex p = new Vertex(x,y,0); //针对每个顶点判断一次 boolean V1 = sameSide(v1,v2,v3,p); boolean V2 = sameSide(v2,v3,v1,p); boolean V3 = sameSide(v3,v1,v2,p); if (V3 && V2 && V1) { img.setRGB(x, y, t.color.getRGB()); } } } } g2.drawImage(img, 0, 0, null);
来看看实际的效果吧!
相信你已经发现问题了:蓝色三角形总是在其他三角形之上。发生这种情况是因为我们目前正在一个接一个地绘制三角形,而蓝色三角形是最后一个 - 因此它被绘制在所有其他三角形之上。
这就引出了下一个概念:z-buffer (或深度缓冲区)的概念
z-buffer它的作用是:在光栅化期间构建一个中间数组,该数组将存储任何给定像素处最后看到的元素的深度。光栅化三角形时,我们将检查像素深度是否小于(因为正向是-z方向)之前看到的,并且仅在像素高于其他像素时对其进行着色。
double[] zBuffer = new double[img.getWidth() * img.getHeight()];// initialize array with extremely far away depthsfor (int q = 0; q < zBuffer.length; q++) { zBuffer[q] = Double.NEGATIVE_INFINITY;}for (Triangle t : tris) { // 之前的代码 if (V3 && V2 && V1) { double depth = v1.z + v2.z + v3.z; int zIndex = y * img.getWidth() + x; if (zBuffer[zIndex] < depth) { img.setRGB(x, y, t.color.getRGB()); zBuffer[zIndex] = depth; } }}
效果:
到目前为止渲染管线看起来一切正常了,但是还缺少了一个重要的效果:阴影
阴影-平面着色在计算机图形学中的“阴影”,可以简单解释为--根据表面的角度和与灯光的距离来改变表面的颜色。
最简单的着色形式是平面着色。它只考虑表面法线和光源方向之间的角度。您只需要找到这两个向量之间的角度余弦并将颜色乘以结果值。这种方法非常简单且快速,因此当更高级的着色技术计算成本太高时,通常用它做高速渲染。
法向量法向量,是空间解析几何的一个概念,垂直于平面的直线所表示的向量为该平面的法向量。法向量适用于解析几何。由于空间内有无数个直线垂直于已知平面,因此一个平面都存在无数个法向量(包括两个单位法向量)。
还记得之前的叉积吗,我们只需要除掉自身的模长即可得到一个法向量
Vertex ab = new Vertex(v2.x - v1.x, v2.y - v1.y, v2.z - v1.z); Vertex ac = new Vertex(v3.x - v1.x, v3.y - v1.y, v3.z - v1.z); //法向量 Vertex norm = new Vertex( ab.y * ac.z - ab.z * ac.y, ab.z * ac.x - ab.x * ac.z, ab.x * ac.y - ab.y * ac.x ); double normalLength = Math.sqrt(norm.x * norm.x + norm.y * norm.y + norm.z * norm.z); norm.x /= normalLength; norm.y /= normalLength; norm.z /= normalLength;点积
点积的定义还是比较抽象的,我们只需要了解其在三维空间中的几何意义,以及公式即可。
公式:
几何意义:第一个向量投影到第二个向量上(这里,向量的顺序是不重要的,点积运算是可交换的),然后通过除以它们的标量长度来“标准化”。这样,这个分数一定是小于等于1的,可以简单地转化成一个角度值即:
光源为了简单起见,我们使用定向光源(光直接位于相机后面无限远的距离),光源方向将是[0 0 1]。现在我们需要计算三角形法向量和光线方向之间的余弦,作为阴影的系数。
在该场景下我们可以得到:
其中A为三角形的法向量,B为光线。
化为代码非常简单:
double angleCos = Math.abs(norm.z);
为了简单处理,在这里我们不关系三角形是否面向相机,但实际上是需要根据光线追踪来判断的(下一篇光线追踪中我们再来完善它)。
现在我们的得到了阴影系数,所以可以简单的处理为:
public static Color getShade(Color color, double shade) { int red = (int) (color.getRed() * shade); int green = (int) (color.getGreen() * shade); int blue = (int) (color.getBlue() * shade); return new Color(red, green, blue);}
效果:
可以看到,虽然有了阴影但是衰减的太快,这是因为Java使用的是sRGB 颜色空间,所以我们需要将每种颜色从缩放格式转换为线性格式,应用阴影,然后再转换sRGB,但是实际的转换过程非常复杂,我们只做简单的近似:
先做2.2次幂到线性空间计算阴影,然后在做1/2.2次幂回到sRGB空间
参数依据在这篇文章:Gamma、Linear、sRGB 和Unity Color Space,你真懂了吗?
现在我们来改进下代码:
public static Color getShade(Color color, double shade) { double redLinear = Math.pow(color.getRed(), 2.2) * shade; double greenLinear = Math.pow(color.getGreen(), 2.2) * shade; double blueLinear = Math.pow(color.getBlue(), 2.2) * shade; int red = (int) Math.pow(redLinear, 1 / 2.2); int green = (int) Math.pow(greenLinear, 1 / 2.2); int blue = (int) Math.pow(blueLinear, 1 / 2.2); return new Color(red, green, blue); }
效果对比:
曲面物体的平面我们可以用三角形简单的拼接进行表示,那么曲面该如何使用三角形表示呢?
一种方式是通过平面的拆分-膨胀来做到。
拆分一个三角形可以通过三个边的中点,来拆分成4个小三角形,如下图:
通过代码可以表示为:
List膨胀result = new ArrayList<>(); for (Triangle t : tris) { Vertex m1 = new Vertex((t.v1.x + t.v2.x) / 2, (t.v1.y + t.v2.y) / 2, (t.v1.z + t.v2.z) / 2); Vertex m2 = new Vertex((t.v2.x + t.v3.x) / 2, (t.v2.y + t.v3.y) / 2, (t.v2.z + t.v3.z) / 2); Vertex m3 = new Vertex((t.v1.x + t.v3.x) / 2, (t.v1.y + t.v3.y) / 2, (t.v1.z + t.v3.z) / 2); result.add(new Triangle(t.v1, m1, m3, t.color,true)); result.add(new Triangle(t.v2, m1, m2, t.color,true)); result.add(new Triangle(t.v3, m2, m3, t.color,true)); result.add(new Triangle(m1, m2, m3, t.color,true)); } }
现在我们获得了一些更小的三角形,现在要做的就是让它们的顶点膨胀到圆弧所在的位置上。
让我们先用二维空间的简单场景来描述这一过程:
通过上图可知:(原位置与原点的距离:L)/(三角形顶点到原点的距离:r)获得一个比例系数;然后用其当前坐标x0,y0分别除以该系数即可。
距离公式:
实际代码如下:
for (Triangle t : result) { for (Vertex v : new Vertex[]{t.v1, t.v2, t.v3}) { double l = Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z) / Math.sqrt(30000); v.x /= l; v.y /= l; v.z /= l; } }
其中3000是某一三角形顶点到原点的距离例如用(100,100,100)这个顶点为例:(100*100+100*100+100*100)=30000
效果让我们先来针对一个面拆分5次然后膨胀,看下效果:
四个面全部膨胀即可得到一个圆形:
然后我们减少拆分次数(2次)看下效果:
结束收工!
参考项目:https://gist.github.com/Rogach/f3dfd457d7ddb5fcfd99/4f2aaf20a468867dc195cdc08a02e5705c2cc95c