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 verticesvar 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 facesthis.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 objectfor (var i = 0, n_obj = objects.length; i < n_obj; ++i) {// For each facefor (var j = 0, n_faces = objects[i].faces.length; j < n_faces; ++j) {// Current facevar face = objects[i].faces[j];// Draw the first vertexvar P = project(face[0]);ctx.beginPath();ctx.moveTo(P.x + dx, -P.y + dy);// Draw the other verticesfor (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 facectx.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 planevar 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.rhkb.cn/news/189506.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

菜单栏管理软件 Bartender 3 mac中文版功能介绍

​Bartender 3 mac是一款菜单栏管理软件&#xff0c;该软件可以将指定的程序图标隐藏起来&#xff0c;需要时呼出即可。 Bartender 3 mac功能介绍 Bartender 3完全支持macOS Sierra和High Sierra。 更新了macOS High Sierra的用户界面 酒吧现在显示在菜单栏中&#xff0c;使其…

Linux系统上搭建高可用Kafka集群(使用自带的zookeeper)

本次在CentOS7.6上搭建Kafka集群 Apache Kafka 是一个高吞吐量的分布式消息系统&#xff0c;被广泛应用于大规模数据处理和实时数据管道中。本文将介绍在CentOS操作系统上搭建Kafka集群的过程&#xff0c;以便于构建可靠的消息处理平台。 文件分享&#xff08;KafkaUI、kafka…

Leetcode421. 数组中两个数的最大异或值

Every day a Leetcode 题目来源&#xff1a;421. 数组中两个数的最大异或值 解法1&#xff1a;贪心 位运算 初始化答案 ans 0。从最高位 high_bit 开始枚举 i&#xff0c;也就是 max⁡(nums) 的二进制长度减一。设 newAns ans 2i&#xff0c;看能否从数组 nums 中选两个…

servlet 的XML Schema从哪边获取

servlet 6.0的规范定义&#xff1a; https://jakarta.ee/specifications/servlet/6.0/ 其中包含的三个XML Schema&#xff1a;web-app_6_0.xsd、web-common_6_0.xsd、web-fragment_6_0.xsd。但这个页面没有给出下载的链接地址。 正好我本机有Tomcat 10.1.15版本的源码&#…

头歌答案--爬虫实战

目录 urllib 爬虫 第1关&#xff1a;urllib基础 任务描述 第2关&#xff1a;urllib进阶 任务描述 requests 爬虫 第1关&#xff1a;requests 基础 任务描述 第2关&#xff1a;requests 进阶 任务描述 网页数据解析 第1关&#xff1a;XPath解析网页 任务描述 第…

基于公共业务提取的架构演进——外部依赖防腐篇

1.背景 有了前两篇的帐号权限提取和功能设置提取的架构演进后&#xff0c;有一个问题就紧接着诞生了&#xff0c;对于诸多业务方来说&#xff0c;关键数据源的迁移如何在各个产品落地&#xff1f; 要知道这些数据都很关键&#xff1a; 对于帐号&#xff0c;获取不到帐号信息是…

如何在后台执行 SwiftData 操作

文章目录 前言Core Data 私有队列上下文SwiftData 并发支持使用 ModelActor合并上下文更改的问题通过标识符访问模型总结 前言 SwiftData 是一个用于处理数据操作的框架&#xff0c;特别是在 Swift 语言中进行并发操作。本文介绍了如何在后台执行 SwiftData 操作以及与 Core D…

基于springboot的在线文档管理系统

基于springboot的在线文档管理系统 摘要 基于Spring Boot的在线文档管理系统是一种通过使用Spring Boot框架构建的现代化应用程序&#xff0c;旨在有效地组织、存储和分享文档内容。该系统通过利用Spring Boot的快速开发和简化配置的优势&#xff0c;提供了一个稳健的基础架构&…

某手游完整性校验分析

前言 只是普通的单机手游&#xff0c;广告比较多&#xff0c;所以分析处理了下&#xff0c;校验流程蛮有意思的&#xff0c;所以就分享出来了 1.重打包崩溃处理 样本进行了加固&#xff0c;对其dump出dex后重打包出现崩溃 ida分析地址发现为jni函数引起 利用Xposed直接替换…

Yolo自制detect训练

Install 把代码拉下来 GitHub - ultralytics/yolov5 at v5.0 然后 pip install -r requirements.txt 安装完了,运行一下detect.py即可 结果会保存在对应的目录下 Intro ├── data:主要是存放一些超参数的配置文件(这些文件(yaml文件)是用来配置训练集和测试集还有验…

基于讯飞星火大语言模型开发的智能插件:小策问答

星火大语言模型是一种基于深度学习的自然语言处理技术&#xff0c;它能够理解和生成人类语言。这种模型的训练过程涉及到大量的数据和复杂的算法&#xff0c;但最终的目标是让机器能够像人一样理解和使用语言。 小策问答是一款基于星火大语言模型的定制化GPT插件小工具。它的主…

ios安全加固 ios 加固方案

​ 目录 一、iOS加固保护原理 1.字符串混淆 2.类名、方法名混淆 3.程序结构混淆加密 4.反调试、反注入等一些主动保护策略 二 代码混淆步骤 1. 选择要混淆保护的ipa文件 2. 选择要混淆的类名称 3. 选择要混淆保护的函数&#xff0c;方法 4. 配置签名证书 5. 混淆和测…

ida81输入密码验证算法分析以及破解思路

本文分析了ida81对输入密码的验证流程&#xff0c;分别对输入密码到生成解密密钥、密码素材的生成过程以及文件数据的加密过程这三个流程进行分析&#xff0c;并尝试找一些可利用的破绽。很遗憾&#xff0c;由于水平有限&#xff0c;目前也只是有个思路未能完全实现&#xff0c…

腾讯域名优惠卷领取

腾讯域名到到期了&#xff0c;听说申请此计划&#xff0c;可获得优惠卷&#xff0c;看到网上5年域名只需要10元&#xff0c;姑且试试看。 我的博客即将同步至腾讯云开发者社区&#xff0c;邀请大家一同入驻&#xff1a;https://cloud.tencent.com/developer/support-plan?in…

“辛巴猫舍”内网渗透、提权、撞库学习笔记

前言&#xff1a; 在拿到靶机时&#xff0c;我们最先需要做的是信息收集&#xff0c;包括不限于&#xff1a;C段扫描&#xff0c;端口探测&#xff0c;指纹识别&#xff0c;版本探测等。其次就是 漏洞挖掘、漏洞利用、提权、维持权限、日志清理、留下后门。 以上就是渗透的基本…

Autosar UDS开发01(UDS诊断入门概念(UDSOnCan))

目录 回顾接触UDS的过程 UDS基本概念 UDS的作用 UDS的宏观认识 UDS的CAN通讯链路 UDS的报文种类 回顾接触UDS的过程 自21年毕业后&#xff0c;我一直干了2年的Autosar CAN通讯开发。 开发的主要内容简单概括就是&#xff1a;应用报文开发、网管报文开发、休眠唤醒开发&am…

【PostgreSql本地备份为dump文件与恢复】使用脚本一键备份为dump文件

环境&#xff1a;windows数据库&#xff1a;postgresql 1.准备脚本 backUpDb.bat 脚本为备份脚本&#xff0c;双击运行&#xff0c;右键可以选择编辑&#xff1b;restoreDb.bat 脚本为恢复脚本&#xff0c;双击运行&#xff0c;右键选择编辑&#xff1b; 1.1 脚本介绍 如上图…

docker更改存储目录原因及方案

为什么一定要将docker的存储目录挂载到其他目录 docker在安装时默认存储目录在/var/lib/docker&#xff0c;而该目录是在系统盘下的。docker安装后&#xff0c;会使用各种各样的镜像&#xff0c;动辄几个G&#xff0c;那么如此多的镜像文件&#xff0c;装着装着系统盘就撑爆了…

【mysql】将逗号分割的字段内容转换为多行并group by

先说需求&#xff1a; 公司想让我通过mysql导出一个报表&#xff0c;内容为公司每个人参加会议的次数&#xff0c;现在有一个会议表fusion_meeting&#xff0c;正常的逻辑是通过人员直接group by就可以得出结果&#xff0c;但是我们的参会人是通过逗号分割这种方式存在一个字段…

网络原理-UDP/TCP详解

一. UDP协议 UDP协议端格式 由上图可以看出&#xff0c;一个UDP报文最大长度就是65535. • 16位长度&#xff0c;表示整个数据报&#xff08;UDP首部UDP数据&#xff09;的最大长度&#xff08;注意&#xff0c;这里的16位UDP长度只是一个标识这个数据报长度的字段&#xff0…