关于后端:了解3D世界的黑魔法纯Java构造一个简单的3D渲染引擎

1次阅读

共计 10431 个字符,预计需要花费 27 分钟才能阅读完成。

简介:对于非渲染引擎相干工作的开发者来说,可能认为即便构建最简略的 3D 程序也十分艰难,但事实上并非如此,本篇文章将通过简略的 200 多行的纯 Java 代码,去实际正交投影、简略三角形光栅化、z 缓冲(深度缓冲区)和立体着色等根本的 3D 渲染技术。

作者 | 李历成 (徜葆) 起源 | 阿里开发者公众号前言当今用于游戏和多媒体的 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 点的办法有很多,但最灵便的是应用矩阵乘法。将点示意为 3×1 向量,而后转换就是简略地乘以 3×3 矩阵。

 例如两倍缩放:

 当然,本次重点解说的是旋转,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 depths
for (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< Triangle> 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)这个顶点为例:(100100+100100+100*100)=30000 成果让咱们先来针对一个面拆分 5 次而后收缩,看下成果:

 四个面全副收缩即可失去一个圆形:

 而后咱们缩小拆分次数(2 次)看下成果:

 完结出工!参考我的项目:https://gist.github.com/Rogac… 举荐浏览:https://www.cnblogs.com/BigFe…、Linear、sRGB 和 Unity Color Space,你真懂了吗?https://zhuanlan.zhihu.com/p/… 举荐浏览:1. 代码圈复杂度治理小结 2. 如何写出无效的单元测试 3. java 利用提速(速度与激情)《〈Java 开发手册(泰山版)〉灵魂 13 问》一线大厂怎么用 Java?看千万浏览量技术博主给你剖析!置信大家都读过《Java 开发手册》泰山版,泰山版新增 5 条日期工夫规约;新增 2 条表别名 sql 规约;新增对立错误码规约。而《〈Java 开发手册(泰山版)〉灵魂 13 问》则是为了帮忙大家更好的了解这些规约背地的原理,从问题重现到原理剖析再到解决问题,全网千万浏览量技术博主 Hollis 带你分析阿里巴巴开发细节。点击这里,查看详情。原文链接:https://click.aliyun.com/m/10… 本文为阿里云原创内容,未经容许不得转载。

正文完
 0