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

在网页中显示图像和其他平面形状非常容易。 然而,当涉及到显示 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

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

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

链表OJ题(2)

目录 1.移除链表元素√ 2.反转链表 3.相交链表 4.链表的中间节点 5.链表中倒数第k个节点 6.合并链表√ 7.分割链表√ 今天链表面试OJ题目 移除链表元素反转链表相交链表链表的中间节点链表中倒数第k个节点合并链表分割链表 &#x1f642;起始条件 中间节点 结束条件&am…

111111111111111

全局锁 就是对整个数据库进行加锁&#xff0c;加锁之后整个数据库就处于只读状态&#xff0c;后续的DML写语句&#xff0c;DDL语句&#xff0c;以及对更新事务的提交操作都会被阻塞&#xff0c;典型地使用场景就是做整个数据库的逻辑备份&#xff0c;对所有的表进行锁定&#x…

动态内存管理(让内存管理更加灵活)

文章目录 概述一、动态内存开辟malloc 函数calloc 函数realloc 函数 二、动态内存释放三、动态内存可能会犯的错误 概述 我们平时在内存中开辟空间的方式有&#xff1a; int a 10;//在栈空间上开辟四个字节int arr[10] {0};//在栈空间上开辟10个字节的连续空间用以上方式开辟…

Clickhouse学习笔记(5)—— ClickHouse 副本

Data Replication | ClickHouse Docs 副本的目的主要是保障数据的高可用性&#xff0c;即使一台 ClickHouse 节点宕机&#xff0c;那么也可以从其他服务器获得相同的数据 注意&#xff1a; clickhouse副本机制的实现要基于zookeeperclickhouse的副本机制只适用于MergeTree f…

【每日一题】情侣牵手

文章目录 Tag题目来源题目解读解题思路方法一&#xff1a;并查集 写在最后 Tag 【并查集】【数组】【2023-11-11】 题目来源 765. 情侣牵手 题目解读 返回最少的交换座位的次数&#xff0c;使每对情侣可以坐在一起。 解题思路 方法一&#xff1a;并查集 对于一对情侣&…

跟着openai学编程

装饰者模式 class Component:def operator(self):passclass ConcreteComponent(Component):def operator(self):return "ConcreteComponent operator"class Decorator(Component):def __init__(self, component) -> None:super().__init__()self.component compo…

SDWAN(Software Defined Wide Area Network)概述与优势分析

文章目录 SDWAN简介SDWAN技术优势简化网络部署和维护安全传输灵活网络拓扑极致体验 SD-WAN关联技术STUNIPsec智能选路SaaS路径优化 典型组网多总部分支组网云管理组网 推荐阅读 SDWAN简介 SDWAN&#xff08;Software Defined Wide Area Network&#xff0c;软件定义广域网&…

Java自学第9课:JSP基础及内置对象

目录&#xff1a; 目录 1 JSP基础知识架构 1 指令标识 1 Page命令 2 Including指令 3 taglib指令 2 脚本标识 1 JSP表达式 2 声明标识 3 代码片段 3 JSP注释 1 HTML注释 2 带有JSP表达式的注释 3 隐藏注释 4 动态注释 4 动作标识 1 包含文件标识 2 请求转发标…

vscode 和 keil协同使用开发stm32程序,超详细教程

vscode 和 keil协同使用开发stm32程序 文章目录 vscode 和 keil协同使用开发stm32程序1. 安装vscode拓展安装chinese插件 2 .安装Mingw3.配置环境变量4. 打开Keil项目 VSCODE 是一款广受好评的代码编辑器&#xff0c; KEIL 是常用的嵌入式开发工具但编程界面简陋。 将两个工具…

【PyQt】(自制类)处理鼠标点击逻辑

写了个自认为还算不错的类&#xff0c;用于简化mousePressEvent、mouseMoveEvent和mouseReleaseEvent中的鼠标信息。 功能有以下几点&#xff1a; 鼠标当前状态&#xff0c;包括鼠标左/中/右键和单击/双击/抬起鼠标防抖(仅超出一定程度时才判断鼠标发生了移动)&#xff0c;灵…

TMUX命令的基本操作和使用

tmux&#xff1a;是两个单词的缩写&#xff0c;即“Terminal MultipleXer”&#xff0c;意思是“终端复用器”。 TMUX使用场景&#xff1a;假如你需要跑大模型或者数据集特别大的AI任务时&#xff0c;它往往需要花较长时间才能跑完&#xff0c;在跑的过程中&#xff0c;不能断…

用朴素贝叶斯实现垃圾邮箱分类实验报告

一、实验目的 1.会用Python创建朴素贝叶斯模型 2.使用朴素贝叶斯模型对垃圾邮件分类 3.会把文本内容变成向量 4.会用评价朴素贝叶斯模型的分类效果 二、设备与环境 Jupyter notebook Python3.9 三、实验原理 四、实验内容 1.把给定的数据集message.csv拆分成训练集和测试集&…

LeetCode【207】课程表

题目&#xff1a; 思路&#xff1a; https://www.jianshu.com/p/25868371ddfc/ 代码&#xff1a; public boolean canFinish(int numCourses, int[][] prerequisites) {// 入度int[] indegress new int[numCourses];// 每个点对应的边,出边Map<Integer, List<Intege…

upload 文件自动上传写法,前后端 下载流文件流

<el-uploadv-model:file-list"fileList":action"app.api/student/student/import":headers"{// Content-Type: multipart/form-data;boundary----split-boundary, 此处切记不要加&#xff0c;否则会造成后端报错 Required request part file is…

Python编程:从入门到实践 (项目3—Web应用程序—学习问题汇总)(新手避坑必看)

本人系统环境&#xff1a; WIN10系统 Python 3.9 Django 2.1.5 书本环境&#xff1a; Python 3.x Django 1.8.5 基于Django 开发一个名为“学习笔记”的项目&#xff0c;这是一个在线的日志系统&#xff0c;能够记录所学习的有关特定主题的知识。 建立项目 要编写一个名为“…

第十周学习记录

阅读MARS MARS创新点&#xff1a; (1)实例感知。模拟器使用独立的网络分别对前景实例和背景环境进行建模&#xff0c;以便可以单独控制实例的静态&#xff08;例如大小和外观&#xff09;和动态&#xff08;例如轨迹&#xff09;属性。 (2)模块化。模拟器允许在不同的 NeRF 主干…

补坑:Java的字符串String类(3):再谈String

不太熟悉字符串的可以看看这两篇文章 补坑&#xff1a;Java的字符串String类&#xff08;1&#xff09;-CSDN博客 补坑&#xff1a;Java的字符串String类&#xff08;2&#xff09;&#xff1a;一些OJ题目-CSDN博客 字符串创建对象 public static void main(String[] args) …

compile: version “go1.19“ does not match go tool version “go1.18.1“

** 1 安装了新版本的go后 为什么go version 还是旧版本&#xff1f; ** 如果你已经按照上述步骤安装了新版本的 Go&#xff0c;但 go version 命令仍然显示旧版本&#xff0c;可能是因为你的环境变量设置不正确或未正确生效。你可以尝试以下方法来解决问题&#xff1a; 重新…

YOLOV5改进:RefConv | 即插即用重参数化重聚焦卷积替代常规卷积,无额外推理成本下涨点明显

1.该文章属于YOLOV5/YOLOV7/YOLOV8改进专栏,包含大量的改进方式,主要以2023年的最新文章和2022年的文章提出改进方式。 2.提供更加详细的改进方法,如将注意力机制添加到网络的不同位置,便于做实验,也可以当做论文的创新点 3.涨点效果:RefConv,实现有效涨点! 论文地址 …

优雅关闭TCP的函数shutdown效果展示

《TCP关闭的两种方法概述》里边理论基础&#xff0c;下边是列出代码&#xff0c;并且进行实验。 服务端代码graceserver.c的内容如下&#xff1a; #include "lib/common.h"static int count;static void sig_int(int signo) {printf("\nreceived %d datagrams\…