3D渲染原理及朴素JavaScript实现【不使用WebGL】

news2024/11/18 8:14:22

在网页中显示图像和其他平面形状非常容易。 然而,当涉及到显示 3D 形状时,事情就变得不那么容易了,因为 3D 几何比 2D 几何更复杂。 为此,你可以使用专用技术和库,例如 WebGL 和 Three.js。

但是,如果你只想显示一些基本形状(例如立方体),则不需要这些技术。 此外,它们不会帮助你了解它们的工作原理以及我们如何在平面屏幕上显示 3D 形状。

本教程的目的是解释如何在没有 WebGL 的情况下为 Web 构建一个简单的 3D 引擎。 我们将首先了解如何存储 3D 形状。 然后,我们将了解如何在两个不同的视图中显示这些形状。
在这里插入图片描述

在线工具推荐: Three.js AI纹理开发包 - YOLO合成数据生成器 - GLTF/GLB在线编辑 - 3D模型格式在线转换 - 可编程3D场景编辑器

1、所有形状都是多面体

虚拟世界与现实世界有一个主要区别:没有什么是连续的,一切都是离散的。 例如,你无法在屏幕上显示完美的圆形,但可以通过绘制具有很多边的正多边形来实现它:边越多,圆就越“完美”。

在 3D 中,这是同样的事情,每个形状都必须用多边形的 3D 等价物来处理:多面体(Polyhedron)。在这种 3D 形状中,我们只能找到平面,而不是球体中的弯曲侧面。 当我们谈论已经是多面体的形状(例如立方体)时,这并不奇怪,但当我们想要显示其他形状(例如球体)时,需要记住这一点。

在这里插入图片描述

2、存储多面体

为了猜测如何存储多面体,我们必须记住如何在数学中识别这样的东西。 你在上学期间肯定已经学过一些基本的几何图形。 例如,要识别一个正方形,你可以将其称为 ABCD,其中 A、B、C 和 D 指的是构成正方形每个角的顶点。

对于我们的 3D 引擎来说,也是一样的。 我们将从存储形状的每个顶点开始。 然后,这个形状将列出它的面,每个面将列出它的顶点。

为了表示一个顶点,我们需要正确的结构。 这里我们创建一个类来存储顶点的坐标。

var Vertex = function(x, y, z) {
    this.x = parseFloat(x);
    this.y = parseFloat(y);
    this.z = parseFloat(z);
};

现在可以像任何其他对象一样创建顶点:

var A = new Vertex(10, 20, 0.5);

接下来,我们创建一个代表多面体的类。 我们以立方体为例。 该类的定义如下,后面有解释。

var Cube = function(center, size) {
    // Generate the vertices
    var d = size / 2;

    this.vertices = [
        new Vertex(center.x - d, center.y - d, center.z + d),
        new Vertex(center.x - d, center.y - d, center.z - d),
        new Vertex(center.x + d, center.y - d, center.z - d),
        new Vertex(center.x + d, center.y - d, center.z + d),
        new Vertex(center.x + d, center.y + d, center.z + d),
        new Vertex(center.x + d, center.y + d, center.z - d),
        new Vertex(center.x - d, center.y + d, center.z - d),
        new Vertex(center.x - d, center.y + d, center.z + d)
    ];

    // Generate the faces
    this.faces = [
        [this.vertices[0], this.vertices[1], this.vertices[2], this.vertices[3]],
        [this.vertices[3], this.vertices[2], this.vertices[5], this.vertices[4]],
        [this.vertices[4], this.vertices[5], this.vertices[6], this.vertices[7]],
        [this.vertices[7], this.vertices[6], this.vertices[1], this.vertices[0]],
        [this.vertices[7], this.vertices[0], this.vertices[3], this.vertices[4]],
        [this.vertices[1], this.vertices[6], this.vertices[5], this.vertices[2]]
    ];
};

使用这个类,我们可以通过指示其中心和边的长度来创建虚拟立方体:

var cube = new Cube(new Vertex(0, 0, 0), 200);

Cube 类的构造函数首先生成立方体的顶点,根据指示中心的位置计算。 一个模式会更清晰,所以请看下面我们生成的八个顶点的位置:
在这里插入图片描述

然后,我们列出面。 每个面都是一个正方形,因此我们需要为每个面指示四个顶点。 在这里,我选择用数组来表示一个面,但如果你需要,也可以为此创建一个专用类。

当我们创建一个面时,我们使用四个顶点。 我们不需要再次指示它们的位置,因为它们存储在 this.vertices[i] 对象中。 这很实用,但我们这样做还有另一个原因。

默认情况下,JavaScript 尝试使用尽可能少的内存。 为了实现这一点,它不会复制作为函数参数传递的对象,甚至不会复制存储到数组中的对象。 对于我们的例子来说,这是完美的行为。

事实上,每个顶点都包含三个数字(它们的坐标),如果我们需要添加它们,还可以加上几个方法。 如果对于每个面,我们存储顶点的副本,我们将使用大量内存,这是无用的。 在这里,我们拥有的只是引用(Reference):坐标(和其他方法)被存储一次,并且仅存储一次。 由于每个顶点由三个不同的面使用,通过存储引用而不是副本,我们将所需的内存除以3(或多或少)!

3、我们需要三角形吗?

如果你玩过 3D(例如使用 Blender 等软件,或 WebGL 等库),也许听说过我们应该使用三角形。 在这里,我选择不使用三角形。

这种选择背后的原因是本文是对该主题的介绍,我们将展示立方体等基本形状。 在我们的例子中,使用三角形来显示正方形比其他任何东西都更复杂。

但是,如果你计划构建一个更完整的渲染器,那么需要知道,一般来说,三角形是首选。 这有两个主要原因:

  • 纹理:出于某些数学原因,为了在面上显示图像,我们需要三角形;
  • 奇怪的面:三个顶点总是在同一平面上。 但是,你可以添加不在同一平面中的第四个顶点,并且可以创建连接这四个顶点的面。 在这种情况下,要绘制它,我们别无选择:我们必须将其分成两个三角形(只需尝试用一张纸即可!)。 通过使用三角形,你可以保持控制并选择分割发生的位置)。

4、作用于多面体

存储引用而不是副本还有另一个优点。 当我们想要修改多面体时,使用这样的系统也会将所需的操作数除以三。

为了理解为什么,让我们再回忆一下我们的数学课。 当你想要平移一个正方形时,你并没有真正平移(translate)它。 事实上,你平移四个顶点,然后加入平移。

在这里,我们也会做同样的事情:我们不会触摸脸部。 我们对每个顶点应用所需的操作,然后就完成了。 当面使用参考时,面的坐标会自动更新。 例如,看看我们如何平移之前创建的立方体:

for (var i = 0; i < 8; ++i) {
    cube.vertices[i].x += 50;
    cube.vertices[i].y += 20;
    cube.vertices[i].z += 15;
}

我们知道如何存储 3D 对象以及如何对它们进行操作。 现在是时候看看如何查看它们了! 但是,首先我们需要一点理论背景,以便理解我们要做什么。

5、投影

目前,我们存储 3D 坐标。 然而,屏幕只能显示 2D 坐标,因此我们需要一种方法将 3D 坐标转换为 2D 坐标:这就是我们在数学中所说的投影。 3D 到 2D 投影是由称为虚拟相机的新对象进行的抽象操作。 该相机获取 3D 对象并将其坐标转换为 2D 坐标,将它们发送到渲染器,渲染器将它们显示在屏幕上。 我们假设我们的相机放置在 3D 空间的原点,因此它的坐标是 (0,0,0)。

从本文开始,我们就讨论了坐标,由三个数字表示:x、y 和 z。 但要定义坐标,我们需要一个基础:z是垂直坐标吗? 它是到顶部还是到底部? 没有通用的答案,也没有惯例,因为事实是你可以选择想要的任何内容。 唯一需要记住的是,当你对 3D 对象进行操作时,必须保持一致,因为公式会根据它而变化。 在本文中,我选择了可以在上面的立方体架构中看到的基础:x 从左到右,y 从后到前,z 从下到上。

现在,我们知道该怎么做了:我们有 (x,y,z) 基础上的坐标,为了显示它们,我们需要将它们转换为 (x,z) 基础上的坐标:因为它是一个平面,我们将 能够显示它们。

不只有一种投影。 更糟糕的是,存在无数种不同的投影! 在本文中,我们将看到两种不同类型的投影,它们是实践中最常用的投影。

6、如何渲染我们的场景

在投影我们的3D对象之前,让我们先编写显示它们的函数。 该函数接受一个数组作为参数,该数组列出了要渲染的对象、必须用于显示对象的画布上下文以及在正确位置绘制对象所需的其他详细信息。

该数组可以包含多个要渲染的对象。 这些对象必须尊重一件事:有一个名为 faces 的公共属性,它是一个列出对象所有面的数组(就像我们之前创建的立方体)。 这些面可以是任何东西(正方形、三角形,甚至十二边形,如果你愿意的话):它们只需要是列出其顶点的数组即可。

让我们看一下该函数的代码,然后是解释:

function render(objects, ctx, dx, dy) {
    // For each object
    for (var i = 0, n_obj = objects.length; i < n_obj; ++i) {
        // For each face
        for (var j = 0, n_faces = objects[i].faces.length; j < n_faces; ++j) {
            // Current face
            var face = objects[i].faces[j];

            // Draw the first vertex
            var P = project(face[0]);
            ctx.beginPath();
            ctx.moveTo(P.x + dx, -P.y + dy);

            // Draw the other vertices
            for (var k = 1, n_vertices = face.length; k < n_vertices; ++k) {
                P = project(face[k]);
                ctx.lineTo(P.x + dx, -P.y + dy);
            }

            // Close the path and draw the face
            ctx.closePath();
            ctx.stroke();
            ctx.fill();
        }
    }
}

这个函数值得一些解释。 更准确地说,我们需要解释这个 project()函数是什么,以及这些 dx和 dy参数是什么。 剩下的基本上就是列出物体,然后画出每个面。

顾名思义, project() 函数的作用是将 3D 坐标转换为 2D 坐标。 它接受 3D 空间中的顶点并返回 2D 平面中如下定义的顶点:

var Vertex2D = function(x, y) {
    this.x = parseFloat(x);
    this.y = parseFloat(y);
};

我没有将坐标命名为 x 和 z,而是选择将 z 坐标重命名为 y,以保持我们在 2D 几何中常见的经典约定,但如果你愿意,也可以保留 z。

project() 的确切内容是我们将在下一节中看到的内容:它取决于你选择的投影类型。 但无论这种类型是什么, render() 函数都可以保持原样。

一旦我们在平面上有了坐标,就可以将它们显示在画布上,这就是我们所做的…有一个小技巧:我们并没有真正绘制由 project()函数返回的实际坐标。

事实上, project() 函数返回虚拟 2D 平面上的坐标,但其原点与我们为 3D 空间定义的坐标原点相同。 然而,我们希望原点位于画布的中心,这就是我们平移坐标的原因:顶点 (0,0) 不在画布的中心,但 (0 + dx,0 + dy) 是, 如果我们明智地选择 dx 和 dy。 由于我们希望 (dx,dy) 位于画布的中心,因此我们没有真正的选择,因此我们定义 dx = canvas.width / 2 和 dy = canvas.height / 2。

最后,最后一个细节:为什么我们使用 -y 而不是直接使用 y? 答案在于我们选择的基础:z 轴指向顶部。 然后,在我们的场景中,具有正 z 坐标的顶点将向上移动。 然而,在画布上,y 轴指向底部:具有正 y 坐标的顶点将向下移动。 这就是为什么我们需要将画布上的 y 坐标定义为场景的 z 坐标的反转。

现在 render() 函数已经很清楚了,是时候看看 project() 了。

7、正交视图

让我们从正交投影(Orthographic Projection)开始。 因为它是最简单的,所以很容易理解我们要做什么。

我们有三个坐标,但我们只需要两个。 在这种情况下,最简单的做法是什么? 删除其中一个坐标。 这就是我们在正交视图中所做的。 我们将删除表示深度的坐标:y 坐标。

function project(M) {
    return new Vertex2D(M.x, M.z);
}

你现在可以测试自本文开头以来我们编写的所有代码:有效! 恭喜,你刚刚在平面屏幕上显示了 3D 对象!

该功能演示可以在这个CodePen查看,你可以通过鼠标旋转立方体来与其进行交互:

在这里插入图片描述

有时,正交视图正是我们想要的,因为它具有保留平行线的优点。 然而,这并不是最自然的景象:我们的眼睛并不是这样看的。 这就是为什么我们会看到第二个投影:透视图。

6、透视图

透视投影(Perspective Projection)比正交投影稍微复杂一些,因为我们需要做一些计算。 然而,这些计算并不那么复杂,你只需要知道一件事:如何使用截距定理(Intercept Theorem)。

为了理解原因,让我们看一下表示正交视图的模式。 我们以正交方式将点投影到平面上:
在这里插入图片描述

但是,在现实生活中,我们的眼睛的行为更像是以下模式:

在这里插入图片描述

基本上我们有两个步骤:

  • 将原始顶点和相机原点连接起来;
  • 投影是这条线与平面的交线。
  • 与正交视图不同,在透视视图中投影平面的确切位置很重要:如果将平面放置在远离相机的位置,则不会获得与将其放置在靠近相机时相同的效果。 这里我们将其放置在距相机距离 d 处。

从 3D 空间中的顶点 M(x,y,z) 开始,我们要计算平面上投影 M’ 的坐标 (x’,z’)。
在这里插入图片描述

为了猜测我们将如何计算这些坐标,让我们从另一个角度来看与上面相同的模式,但从顶部看:
在这里插入图片描述

我们可以识别截距定理中使用的配置。 在上面的模式中,我们知道一些值:x、y 和 d 等。我们想要计算 x’,因此我们应用截距定理并得到这个方程: x’ = d / y * x。

现在,如果你从侧面观察同一场景,会得到一个类似的模式,允许通过 z、y 和 d 获得 z’ 的值: z’ = d / y * z。

我们现在可以使用透视图编写 project()函数:

function project(M) {
    // Distance between the camera and the plane
    var d = 200;
    var r = d / M.y;

    return new Vertex2D(r * M.x, r * M.z);
}

这个功能可以在这个CodePen实例中进行测试,你可以再次与立方体进行交互:
在这里插入图片描述

7、结束语

我们的(非常基本的)3D 引擎现在已准备好显示我们想要的任何 3D 形状。 你可以采取一些措施来增强它。 例如,我们可以看到自己形状的每一个面,甚至是后面的脸。 要隐藏它们,你可以实施背面剔除(back-face culling)。

另外,我们没有讨论纹理(texture)。 在这里,我们所有的形状都具有相同的颜色。 例如,你可以通过在对象中添加颜色属性来更改它,以了解如何绘制它们。 你甚至可以为每个面选择一种颜色,而无需更改很多内容。 你还可以尝试在面上显示图像。 然而,这更困难,并且详细说明如何做这样的事情需要整篇文章。

其他事情可以改变。 我们将相机放置在空间的原点,但你可以移动它(在投影顶点之前需要更改基础)。 另外,这里绘制了放置在相机后面的顶点,这不是我们想要的。 裁剪平面可以解决这个问题(易于理解,但不太容易实现)。

正如你所看到的,我们在这里构建的 3D 引擎还远未完成,这也是我自己的解释。 你可以添加自己的其他类:例如,Three.js 使用专用类来管理相机和投影。 此外,我们使用基本数学来存储坐标,但如果你想创建更复杂的应用程序,并且例如需要在帧期间旋转大量顶点,你将不会获得流畅的体验。 为了优化它,你将需要一些更复杂的数学:齐次坐标和四元数。


原文链接:3D渲染原理与JS实现 — BimAnt

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1196651.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

SSH 远程登录 WSL

更新ssh设置 sudo apt-get update sudo apt-get remove openssh-server sudo apt-get install openssh-server 编辑网络配置 sudo vi /etc/ssh/sshd_config &#xff08;1&#xff09;修改ssh服务监听端口和监听地址 &#xff08;2&#xff09;修改ssh服务允许使用用户名密码…

KEIL MDK 调试 无法 查看 外设 信息 原因及解决方法

MDK5.38版本有bug : 不能把STM32F4的官方SVD文件转换成SFR&#xff0c;而MDK5.38a版本没有此问题。

【操作系统内核】进程

【操作系统内核】进程 进程的组成 进程的运行&#xff0c;需要考虑 磁盘 > 内存 > CPU > 内核 > 进程切换 这个过程 首先&#xff0c;程序运行要将可执行文件加载到内存&#xff0c;所以进程要读取可执行文件(运行后可能还需要读取其他文件的数据)&#xff0c;需…

JUC包下面的四大天王+线程池部分知识

一)Semphore:限流器用我就对了 Java中信号量Semphore是把操作系统原生的信号量封装了一下&#xff0c;本质就是一个计数器&#xff0c;描述了 可用资源的个数&#xff0c;主要涉及到两个操作 如果计数器为0了&#xff0c;继续Р操作&#xff0c;就会出现阻塞等待的情况 P操作:申…

徒步“三色”泸溪 共赏冬日胜景

&#xff08;金笛 胡灵芝&#xff09;11月11日&#xff0c;“中国体育彩票”2023年“走红军走过的路”徒步穿越系列活动&#xff08;泸溪站&#xff09;暨泸溪文旅推荐活动在泸溪县举行&#xff0c;来自全国各地千余名户外爱好者通过徒步的方式&#xff0c;传承红色基因&#x…

C语言--输入10个数字,要求输出其中值最大的元素和该数字是第几个数

今天小编带大家了解一下什么是“打擂台”算法。 一.思路分析 可以定义一个数组arr&#xff0c;长度为10&#xff0c;用来存放10个数字&#xff0c;设计一个函数Max&#xff0c;用来求两个数中的较大值&#xff0c; 定义一个临时变量tmparr[0],保存临时最大的值&#xff0c;下标…

邻接表储存图实现广度优先遍历(C++)

目录 基本要求&#xff1a; 邻接表的结构体&#xff1a; 图的邻接表创建&#xff1a; 图的广度优先遍历&#xff08;BFS&#xff09;&#xff1a; 邻接表的打印输出&#xff1a; 完整代码&#xff1a; 测试数据&#xff1a; 结果运行&#xff1a; 通过给出的图的顶点和…

归并排序 merge Sort + 图解 + 递归 / 非递归

归并排序(merge sort)的主要思想是&#xff1a;将若干个有序序列逐步归并&#xff0c;最终归并为一个有序序列二路归并排序(2-way merge sort)是归并排序中最简单的排序方法 &#xff08;1&#xff09;二路归并排序的递归实现 // 二路归并排序的递归实现 void merge(vector&l…

EFCore: The ConnectionString property has not been initialized.

使用NuGet的程序包管理控制台执行命令“update-database”的时候报出该错误 经过检查发现是optionsBuilder.UseSqlServer(strConn);中的strConn没有写

数据库01-慢查询优化

目录 MySQL优化 慢查询 如何定位慢查询&#xff1f; 如何分析慢查询&#xff1f; MySQL优化 MySQL 优化是数据库管理和应用性能调优的一个重要方面。以下是一些常规性的 MySQL 优化经验和适用场景&#xff1a; 索引优化&#xff1a; 确保表的字段上有适当的索引&#xff0…

计算机毕业设计选题推荐-农产品销售微信小程序/安卓APP-项目实战

✨作者主页&#xff1a;IT研究室✨ 个人简介&#xff1a;曾从事计算机专业培训教学&#xff0c;擅长Java、Python、微信小程序、Golang、安卓Android等项目实战。接项目定制开发、代码讲解、答辩教学、文档编写、降重等。 ☑文末获取源码☑ 精彩专栏推荐⬇⬇⬇ Java项目 Python…

C语言全部关键字解析

前言 C语言具有以下关键字&#xff1a; 这些关键字如下: 关键字autobreakcasecharconstcontinuedefaultdodoubleelseenumexternfloatforgotoifintlongregisterreturnshortsignedsizeofstaticstructswitchtypedefunionunsignedvoidvolatilewhile 对于这些关键字&#xff0c;大…

牛客网刷题笔记131111 Python实现LRU+二叉树先中后序打印+SQL并列排序

从学校步入职场一年多&#xff0c;已经很久没刷过题了&#xff0c;为后续稍微做些提前的准备&#xff0c;还是重新开始刷刷题。 从未做过计划表&#xff0c;这回倒是做了个计划表&#xff0c;希望能坚持吧。 刷题比较随性且量级不大&#xff0c;今天就写了2个算法2个sql&#x…

第四节(2):修改WORD中表格数据的方案

《VBA信息获取与处理》教程(10178984)是我推出第六套教程&#xff0c;目前已经是第一版修订了。这套教程定位于最高级&#xff0c;是学完初级&#xff0c;中级后的教程。这部教程给大家讲解的内容有&#xff1a;跨应用程序信息获得、随机信息的利用、电子邮件的发送、VBA互联网…

Qt界面设计时使各控件依据窗口缩放进行自适应填充的方法——使用布局、Spacer等控件

Qt界面设计时使各控件依据窗口缩放进行自适应填充的方法—使用布局、Spacer等控件 Chapter1 Qt界面设计时使各控件依据窗口缩放进行自适应填充的方法—使用布局、Spacer等控件Chapter2 Qt Creator中布局器详解01. 概述02. 开发环境03. 布局器概述04. 布局属性设置05. 弹簧条属性…

简单版本管理服务编写

说明: 制作android应用内更新的时候&#xff0c;经常会用到版本检查&#xff0c;下载&#xff0c;安装&#xff0c;这时候需要写一个版本管理服务。 本文说明了自己编写版本服务的简单经过。 解决方案: 该软件实现如下功能&#xff1a; 创建后台接口&#xff1a;版本软件上传…

基于PHP的设云尘资讯网站设计与实现

项目描述 临近学期结束&#xff0c;还是毕业设计&#xff0c;你还在做java程序网络编程&#xff0c;期末作业&#xff0c;老师的作业要求觉得大了吗?不知道毕业设计该怎么办?网页功能的数量是否太多?没有合适的类型或系统?等等。这里根据疫情当下&#xff0c;你想解决的问…

保姆级自定义GPTs教程,无需任何代码!

11月10日&#xff0c;OpenAI正式宣布向所有ChatGPT Plus用户开放GPTs功能&#xff0c;一个人人都能开发自定义ChatGPT助手的时代降临。 GPTs支持无代码、可视化点击操作&#xff0c;这意味着即便你没有任何编程经验&#xff0c;只要有数据、脑洞大开的想法&#xff0c;就能开发…

探索微信小程序框架的精华——高质量的优秀选择

目录 引言&#xff1a; 1. 框架性能 2. 开发者工具支持 3. 文档和社区支持 4. 扩展能力 5. 使用率和稳定性 结语&#xff1a; 引言&#xff1a; 微信小程序作为一种轻量级、高效便捷的应用形式&#xff0c;已经在移动应用领域占据了重要地位。而其中&#xff0c;选择一个…

PostMan授权认证使用

Authorization 对于很多应用&#xff0c;出于安全考虑我们的接口并不希望对外公开。这个时候就需要使用授权(Authorization)机制。 授权过程验证您是否具有访问服务器所需数据的权限。 当发送请求时&#xff0c;通常必须包含参数&#xff0c;以确保请求具有访问和返回所需数据…