2024年7月

分布式存储的作用
加入分布式存储的目的:主要是为了对数据进行保护避免因一台服务器磁盘的损坏,导致数据丢失不能正常使用。
参考文档:https://gowinder.work/post/proxmox-ve-%E9%83%A8%E7%BD%B2%E5%8F%8C%E8%8A%82%E7%82%B9%E9%9B%86%E7%BE%A4%E5%8F%8Aglusterfs%E5%88%86%E5%B8%83%E5%BC%8F%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F/
需要保存两个pve节点的版本一样
两个proxmox ve节点,实现高可用vm,lxc自动迁移

1.修改hosts文件

在两台pve的/etc/hosts中,增加如下host
root@pve1:~# cat /etc/hosts192.168.1.50pve1.local pve1192.168.1.60pve2.local pve2192.168.1.50gluster1192.168.1.60 gluster2

2.修改服务器名

在两台pve的/etc/hostname中,增加如下
root@pve1:
~# cat /etc/hostnamepve1

root@pve2:
~# cat /etc/hostnamepve2

3.安装glusterfs

以下操作都在两台机器上做, 这里分别为pve1, pve2
wget -O - https://download.gluster.org/pub/gluster/glusterfs/9/rsa.pub | apt-key add -
DEBID=$(grep 'VERSION_ID=' /etc/os-release | cut -d '=' -f 2 | tr -d '"') 
DEBVER
=$(grep 'VERSION=' /etc/os-release | grep -Eo '[a-z]+')
DEBARCH
=$(dpkg --print-architecture)echo deb https://download.gluster.org/pub/gluster/glusterfs/LATEST/Debian/${DEBID}/${DEBARCH}/apt ${DEBVER} main > /etc/apt/sources.list.d/gluster.list apt update [备注这一步可以不做,当源已经确定使用具体源时]
apt
install -y glusterfs-server
3.1.需要保存gluster的版本一样
gluster --version
3.2在pve1上编辑: nano /etc/glusterfs/glusterd.vol, 在 option transport.socket.listen-port 24007 增加:
option transport.rdma.bind-address gluster1 
option transport.socket.bind
-address gluster1
option transport.tcp.bind
-address gluster1
3.3在pve2上编辑: nano /etc/glusterfs/glusterd.vol, 在 option transport.socket.listen-port 24007 增加:
option transport.rdma.bind-address gluster2 
option transport.socket.bind
-address gluster2
option transport.tcp.bind
-address gluster2
3.4开启服务
systemctl enable glusterd.service 
systemctl start glusterd.service

3.5重要,在pve2上需要执行命令以加入集群
gluster peer probe gluster1
显示: peer probe: success 就OK

3.6增加volume
先决条件:需要自己每台服务器上存在数据盘并且已经挂载到/data目录下。
gluster volume create VMS replica 2 gluster1:/data/s gluster2:/data/s
gluster vol start VMS
命令分析*gluster volume create: 这是 GlusterFS 的命令,用于创建一个新的卷。*VMS: 这是你给新卷指定的名称。在这个例子中,卷的名称是 VMS。* replica 2: 这指定了卷的类型和复制因子。replica 表示这是一个复制卷,2表示数据将在两个节点上进行复制。这意味着你有两个副本的数据,一个在主节点上,另一个在复制节点上。* gluster1:/data/s: 这是第一个存储路径。gluster1 是 GlusterFS 集群中的一个节点的名称或 IP 地址,/data/s 是该节点上用于存储 GlusterFS 卷数据的目录。* gluster2:/data/s: 这是第二个存储路径。与第一个路径类似,但指定了第二个节点和存储目录。在这个例子中,数据将被复制到 gluster1 和 gluster2 这两个节点上的 /data/s 目录。
当执行这个命令时,GlusterFS 会在 gluster1 和 gluster2 这两个节点上创建一个名为 VMS 的复制卷,并将数据在两个节点的
/data/s 目录中进行复制。这样做可以提高数据的可靠性和可用性,因为如果其中一个节点出现故障,另一个节点上的数据副本仍然可用。
然而,正如你遇到的错误消息所提到的,创建复制卷(特别是只有两个副本时)有脑裂(
split-brain)的风险。脑裂是指当两个或多个节点都认为自己是主节点并且都在接受写操作时,数据可能会变得不一致。为了避免这种情况,可以使用仲裁节点(arbiter)或将复制因子增加到 3或更多。但在许多情况下,简单的双节点复制卷对于大多数应用来说已经足够了。-------------------------------gluster vol start VMS命令分析1. 启动卷服务:该命令会启动 GlusterFS 集群中名为 VMS 的卷的服务,使得客户端可以开始访问该卷上的数据。2. 确保数据可用性:当卷启动后,GlusterFS 会确保数据在集群中的节点之间是可用的,并会根据卷的类型(如分布式复制卷)来管理和复制数据。3. 检查节点状态:在启动卷之前,GlusterFS 会检查集群中所有参与该卷的节点的状态,确保它们都是可用的并且处于正确的配置中。4. 处理客户端请求:一旦卷启动成功,客户端就可以通过挂载该卷来访问存储在上面的数据。GlusterFS 会处理来自客户端的读写请求,并确保数据在集群中的一致性。5. 负载均衡:对于分布式卷和分布式复制卷,GlusterFS 会在启动时自动进行负载均衡,确保数据在各个节点之间均匀分布,从而提高整体性能和可靠性。6. 监控和日志记录:在卷启动后,GlusterFS 会持续监控该卷的状态和性能,并记录相关的日志信息。这些信息对于后续的故障排查和性能调优非常有用。
综上所述,gluster volume start VMS 命令的作用是启动 GlusterFS 集群中名为 VMS 的卷,确保数据的可用性、一致性和性能,并处理来自客户端的读写请求。在执行该命令之前,需要确保 GlusterFS 集群中的所有节点都已正确配置并可以相互通信。
3.7.检查状态
gluster vol info VMS
gluster vol status VMS

3.8增加挂载

在两台pve上都要做
mkdir /vms
修改pve1的/etc/fstab,增加
gluster1:VMS /vms glusterfs defaults,_netdev,x-systemd.automount,backupvolfile-server=gluster2 0 0
修改pve2的/etc/fstab,增加
gluster2:VMS /vms glusterfs defaults,_netdev,x-systemd.automount,backupvolfile-server=gluster1 0 0
重启两台pve,让/mnt挂载

两台pve不重启挂载

mount /vms

3.9解决split-brain问题

两个节点的gluster会出现split-brain问题,就是两节点票数一样,谁也不听谁的,解决办法如下:
gluster vol set VMS cluster.heal
-timeout 5gluster volume heal VMS enable
gluster vol set VMS cluster.quorum
-reads falsegluster vol set VMS cluster.quorum-count 1gluster vol set VMS network.ping-timeout 2gluster volume set VMS cluster.favorite-child-policy mtime
gluster volume heal VMS granular
-entry-heal enable
gluster volume set VMS cluster.data
-self-heal-algorithm full

4.0pve双节点集群设置

第一个创建,第二个加入,没什么好说的【参考PVE组建集群】

5.0创建共享目录

在DataCenter中的Storage中,点Add,Directory填/vms, 钩选 share

6.0HA设置

修改 /etc/pve/corosync.conf
在quorum中增加,变成这样:
quorum {
provider: corosync_votequorum
expected_votes:
1two_node:1}

6.1配置自动故障转移进入HA

当PVe2节点发生挂断的情况出现,虚拟机会自动漂移至另一台PVE1下

1 介绍

DNS(Domain Name System,域名系统)是一种服务,它是域名和IP地址相互映射的一个分布式数据库,能够使人更方便的访问互联网,而不用去记住能够被机器直接读取的IP地址数串。
简单来说,DNS就是一个将我们输入的网址(比如www.baidu.com )转换成对应的IP地址(比如192.0.2.1)的系统。这个过程是自动且透明的,用户在浏览器中输入网址后,浏览器会向DNS服务器发起查询请求,DNS服务器会根据域名解析出对应的IP地址,然后浏览器再根据这个IP地址去访问目标服务器。

2 实现原理

DNS系统的工作原理大致如下:

2.1 递归查询

当客户端(如浏览器)需要解析一个域名时,它会首先向本地DNS服务器(如ISP提供的DNS服务器)发起查询请求。如果本地DNS服务器缓存中没有该域名的记录,它会向根DNS服务器发起查询。根DNS服务器会返回顶级域(TLD,如.com、.net等)的DNS服务器地址。然后,本地DNS服务器会向这些顶级域DNS服务器发起查询,顶级域DNS服务器再返回下一级DNS服务器的地址,直至找到最终的IP地址。这个过程中,本地DNS服务器会递归地查询,直到找到结果或确定查询失败。

image

递归查询是由DNS服务器主动帮主机查询的查询模式。‌

2.2 迭代查询

与递归查询不同,迭代查询中,本地DNS服务器在收到客户端的查询请求后,会向根DNS服务器发起查询,但根DNS服务器不会直接返回IP地址,而是返回下一级DNS服务器的地址。本地DNS服务器会再次向这个地址发起查询,以此类推,直到找到最终的IP地址。在这个过程中,每个DNS服务器只负责返回下一级DNS服务器的地址,而不是直接返回IP地址。

迭代查询则是客户端自己逐步查询,‌直到获得结果或遍历所有可能的查询途径。‌

2.3 强大的域名解析能力

DNS不仅支持
A记录
(将域名映射到IPv4地址),还支持
AAAA记录
(将域名映射到IPv6地址)、
CNAME记录
(别名记录,将域名映射到另一个域名)、
MX记录
(邮件交换记录,指定处理该域名邮件的邮件服务器)等多种记录类型,以满足不同的需求。

3 在互联网架构中的作用

我们先看一个Http请求,从客户端开始调用,到服务端响应,它的整个LifeCycle,以及DNS起到的作用
image
流程步骤如下:

  1. Client访问域名 www.taobao.com 请求到 DNS 服务器
  2. DNS服务器返回域名对应的外网IP地址:10.88.0.1,这是代理服务Nginx的地址
  3. Client继续访问外网IP 10.88.0.1 向Nginx进行链接
  4. Nginx配置了n个Service(多副本模式)的内网IP,如
    192.168.0.100、192.168.0.101、192.168.0.102
  5. Nginx的负载均衡通过流量调度策略(如 RR)对IP List进行轮询
  6. 请求最终落到某一个Service进行处理,获得计算结果

这是DNS最基本的能力,那除了DNS的A记录解析,在互联网架构中,他还有哪些贡献?

3.1 反向代理和动态扩展

反向代理是一种位于服务器和客户端之间的代理服务器。客户端将请求发送给反向代理,然后由代理服务器根据一定的规则将请求转发给后端服务器。后端服务器将响应返回给代理服务器,再由代理服务器将响应转发给客户端。
反向代理对客户端是透明的,客户端无需知道实际服务器的地址,只需将反向代理当作目标服务器一样发送请求就可以了。
用户在Client只需要记住
www.taobao.com
,不需要知道他后面负载了多少真实的服务,这个就为扩展提供了很多便利,所以原来的架构可以优化为:

image

对同一个域名配置多个Nginx Service 的IP,每当DNS解析请求,RR轮询返回不同的Nginx IP地址,实现动态扩展的能力。

3.2 负载均衡

DNS轮询是一种简单的负载均衡方法,通过改变DNS解析结果中的IP地址顺序,将用户请求分散到不同的服务器上。我们的上图中,Nginx承担了这一层职责,我们可以尝试免去Nginx后看看效果怎么样!

image

看着是去掉了一层网络请求,但是这种也存在一些问题。

  1. 无法实现智能的负载均衡

这种技能支持简单的轮询,无法支持更智能的
Weighted Round Robin

IP Hash

Least Connections
等负载策略

  1. 无法实现探活和故障转移

使用Nginx做反向代理时,可以对Service进行存活探测,当服务挂掉的时候,进行流量迁移,实现故障转移和止损的目标。

3.3 智能路由和加速

智能DNS:
智能DNS可以根据用户的地理位置、网络状况等因素,将用户请求解析到最适合的服务器上,从而提高访问速度和用户体验。
CDN(内容分发网络):
CDN利用DNS技术将用户的请求解析到距离用户最近的缓存节点上,从而加快内容的传输速度,减少网络延迟。

如下图,虽然潮州在广东,但明显离厦门更近,所以流量分发到厦门机房:
image

image

4 总结

  • 动态扩展反向代理层
  • 支持简易轮询模式的负载均衡,但是无法探活 和 Fail Over
  • 智能Dns路由和CDN加速

为什么要为Vue3提供ioc容器

Vue3因其出色的响应式系统,以及便利的功能特性,完全胜任大型业务系统的开发。但是,我们不仅要能做到,而且要做得更好。大型业务系统的关键就是解耦合,从而减缓shi山代码的生长。而ioc容器是目前最好的解耦合工具。Angular从一开始就引入了ioc容器,因此在业务工程化方面一直处于领先地位,并且一直在向其他前端框架招手:“我在前面等你们,希望三年后能再见”。那么,我就试着向前走两步,在Vue3中引入ioc容器,并以此为基础扩充其他工程能力,得到一个新框架:
Zova
。诸君觉得是否好用,欢迎拍砖、交流:

IOC容器分类

在 Zova 中有两类 ioc 容器:

  1. 全局ioc容器
    :在系统初始化时,会自动创建唯一一个全局 ioc 容器。在这个容器中创建的Bean实例都是单例模式
  2. 组件实例ioc容器
    :在创建 Vue 组件实例时,系统会为每一个 Vue 组件实例创建一个 ioc 容器。在这个容器中创建的Bean实例可以在组件实例范围之内共享数据和逻辑

Bean Class分类

在 Zova 中有两类 Bean Class:

  1. 匿名bean
    :使用
    @Local
    装饰的 class 就是
    匿名bean
    。此类 bean 仅在模块内部使用,不存在命名冲突的问题,定义和使用都很便捷
  2. 具名bean
    :除了
    @Local
    之外,其他装饰器函数装饰的 class 都是
    具名bean
    。Zova 为此类 bean 提供了命名规范,既可以避免命名冲突,也有利于跨模块使用

注入机制

Zova 通过
@Use
装饰器函数注入 Bean 实例,提供了以下几种注入机制:

1. Bean Class

通过
Bean Class
在 ioc 容器中查找并注入 Bean 实例,如果不存在则自动创建。这种机制一般用于
同模块注入

import { ModelTodo } from '../../bean/model.todo.js';

class ControllerTodo {
  @Use()
  $$modelTodo: ModelTodo;
}

2. Bean标识

通过
Bean标识
在 ioc 容器中查找并注入 Bean 实例,如果不存在则自动创建。这种机制一般用于
跨模块注入

层级注入

import type { ModelTabs } from 'zova-module-a-tabs';

class ControllerLayout {
  @Use('a-tabs.model.tabs')
  $$modelTabs: ModelTabs;
}
  • 通过
    a-tabs.model.tabs
    查找并注入 Bean 实例
  • 因此,只需导入 ModelTabs 的 type 类型,从而保持模块之间的松耦合关系

3. 注册名

通过
注册名
在 ioc 容器中查找并注入 Bean 实例,如果不存在则返回空值。这种机制一般用于
同模块注入

层级注入

import type { ModelTodo } from '../../bean/model.todo.js';

class ControllerTodo {
  @Use({ name: '$$modelTodo' })
  $$modelTodo: ModelTodo;
}
  • 通过注册名
    $$modelTodo
    查找并注入 Bean 实例。一般而言,应该确保在 ioc 容器中已经事先注入过 Bean 实例,否则就会返回空值

4. 属性名

通过
属性名
在 ioc 容器中查找并注入 Bean 实例,如果不存在则返回空值。这种机制一般用于
同模块注入

层级注入

import type { ModelTodo } from '../../bean/model.todo.js';

class ControllerTodo {
  @Use()
  $$modelTodo: ModelTodo;
}
  • 通过属性名
    $$modelTodo
    查找并注入 Bean 实例。一般而言,应该确保在 ioc 容器中已经事先注入过 Bean 实例,否则就会返回空值

注入范围

匿名bean
的默认注入范围都是
ctx

具名bean
可以在定义时指定默认注入范围,不同的场景(scene)有不同的默认注入范围。 此外,在实际注入时,还可以在@Use 中通过
containerScope
选项覆盖默认的注入范围

Zova 提供了以下几种注入范围:
app/ctx/new/host/skipSelf

1. app

如果注入范围是 app,那么就在全局 ioc 容器中注入 bean 实例,从而实现单例的效果

// in module: test-module1
@Store()
class StoreCounter {}
// in module: test-module2
import type { StoreCounter } from 'zova-module-test-module1';

class Test {
  @Use('test-module1.store.counter')
  $$storeCounter: StoreCounter;
}
  • Store 的注入范围默认是 app,因此通过 Bean 标识
    test-module1.store.counter
    在全局 ioc 容器中查找并注入 bean 实例

2. ctx

如果注入范围是 ctx,那么就在当前组件实例的 ioc 容器中注入 bean 实例

// in module: a-tabs
@Model()
class ModelTabs {}
// in module: test-module2
import type { ModelTabs } from 'zova-module-a-tabs';

class ControllerLayout {
  @Use('a-tabs.model.tabs')
  $$modelTabs: ModelTabs;
}
  • Model 的注入范围默认是 ctx,因此通过 Bean 标识
    a-tabs.model.tabs
    在当前组件实例的 ioc 容器中查找并注入 bean 实例

3. new

如果注入范围是 new,那么就直接创建新的 bean 实例

// in module: a-tabs
@Model()
class ModelTabs {}
// in module: test-module2
import type { ModelTabs } from 'zova-module-a-tabs';

class ControllerLayout {
  @Use({ beanFullName: 'a-tabs.model.tabs', containerScope: 'new' })
  $$modelTabs: ModelTabs;
}
  • 由于指定 containerScope 选项为 new,因此通过 Bean 标识
    a-tabs.model.tabs
    直接创建新的 bean 实例

层级注入

注入范围除了支持
app/ctx/new
,还支持层级注入:
host/skipSelf

4. host

如果注入范围是 host,那么就在当前组件实例的 ioc 容器以及所有父容器中依次查找并注入 bean 实例,如果不存在则返回空值

// in parent component
import type { ModelTabs } from 'zova-module-a-tabs';

class Parent {
  @Use('a-tabs.model.tabs')
  $$modelTabs: ModelTabs;
}
// in child component
import type { ModelTabs } from 'zova-module-a-tabs';

class Child {
  @Use({ containerScope: 'host' })
  $$modelTabs: ModelTabs;
}
  • 由于父组件已经注入了 ModelTabs 的 bean 实例,因此子组件可以直接查找并注入
  • 层级注入
    同样支持所有注入机制:
    Bean Class/Bean标识/注册名/属性名

5. skipSelf

如果注入范围是 skipSelf,那么就在所有父容器中依次查找并注入 bean 实例,如果不存在则返回空值

Zova已开源:
https://github.com/cabloy/zova

2024-07-31:用go语言,给定两个正整数数组arr1和arr2,我们要找到属于arr1的整数x和属于arr2的整数y组成的所有数对(x, y)中,具有最长公共前缀的长度。

公共前缀是指两个数的最左边的一位或多位数字相同的部分。例如,对于整数5655359和56554来说,它们的公共前缀是565,而对于1223和43456来说,它们没有公共前缀。

我们需要找出所有数对(x, y)中具有最长公共前缀的长度是多少,如果没有公共前缀则返回0。

输入:arr1 = [1,10,100], arr2 = [1000]

输出:3

解释:存在 3 个数对 (arr1[i], arr2[j]) :

(1, 1000) 的最长公共前缀是 1 。(10, 1000) 的最长公共前缀是 10 。(100, 1000) 的最长公共前缀是 100 。

最长的公共前缀是 100 ,长度为 3 。

答案2024-07-31:

chatgpt

题目来自leetcode3043。

大体步骤如下:

要解决给定问题,主要分为以下大体步骤:

  1. 初始化一个集合
    :创建一个映射(集合)
    has
    ,用于存储
    arr1
    中所有整数的前缀。这个集合将用于后续查找整数是否在
    arr1
    中的某个前缀。

  2. 提取前缀
    :遍历
    arr1
    中的每个整数,对于每个整数,计算其每个可能的前缀(即数字逐位除以10,直到数字为0),并将每个前缀存入
    has
    集合中。这将使得
    has
    含有
    arr1
    中所有数字的所有前缀。

  3. 初始化一个最大值
    :设置一个变量
    mx
    ,用于记录在
    arr2
    中找到的最大公共前缀。

  4. 查找公共前缀
    :遍历
    arr2
    中的每个整数,对于每个整数,计算其每个可能的前缀(同样逐位除以10),并在集合
    has
    中检查该前缀是否存在。如果存在,则更新
    mx
    为当前整数的前缀值,与当前存储的
    mx
    进行比较,保留较大的值。

  5. 计算结果
    :检查
    mx
    的值,如果
    mx
    为0,表示没有找到公共前缀,返回0。若
    mx
    不为0,计算其对应的长度,即将
    mx
    转为字符串并取其长度,然后返回这个长度作为结果。

  6. 输出结果
    :通过主函数调用
    longestCommonPrefix
    函数,传递两个整数数组,然后打印返回的最长公共前缀的长度。

时间复杂度:

  • 遍历数组
    arr1

    arr2
    的时间复杂度是O(n * k),其中n是
    arr2
    的长度,k是数字的位数(前缀寻找的迭代次数)。但是由于数字的位数是有限的,我们可以认为k是一个常数。因此主要复杂度由遍历造成,即O(n)。

额外空间复杂度:

  • 使用集合
    has
    存储前缀,每个整数的前缀数量最多为其位数,因此在最坏情况下,空间复杂度是O(m * k),其中m是
    arr1
    的长度,k是数字的位数(符合前缀数量)。但是由于k是常数,所以可以简化为O(m)。总体来说,这个算法在空间上的额外消耗是O(m)。

Go完整代码如下:

package main

import (
	"fmt"
	"strconv"
)

func longestCommonPrefix(arr1, arr2 []int) int {
	has := map[int]bool{}
	for _, v := range arr1 {
		for ; v > 0; v /= 10 {
			has[v] = true
		}
	}

	mx := 0
	for _, v := range arr2 {
		for ; v > 0 && !has[v]; v /= 10 {
		}
		mx = max(mx, v)
	}
	if mx == 0 {
		return 0
	}
	return len(strconv.Itoa(mx))
}

func main() {
	arr1 := []int{1, 10, 100}
	arr2 := []int{1000}
	fmt.Println(longestCommonPrefix(arr1, arr2))
}

在这里插入图片描述

Python完整代码如下:

# -*-coding:utf-8-*-

def longest_common_prefix(arr1, arr2):
    has = set()
    
    # 将 arr1 中的所有数字的每个前缀加入集合
    for v in arr1:
        while v > 0:
            has.add(v)
            v //= 10
    
    mx = 0
    
    # 在 arr2 中找到最大的与 arr1 前缀相同的数字
    for v in arr2:
        while v > 0 and v not in has:
            v //= 10
        mx = max(mx, v)
    
    if mx == 0:
        return 0
    
    return len(str(mx))

if __name__ == "__main__":
    arr1 = [1, 10, 100]
    arr2 = [1000]
    print(longest_common_prefix(arr1, arr2))

在这里插入图片描述

写在前面

笔者前段时间开启了一个新的系列《Wgpu图文详解》,在编写的过程中,发现使用wgpu只是应用层面的内容。要想很好的介绍wgpu,不得不将图形学中的一些理论知识进行讲解。但是放在《Wgpu图文详解》这个系列里又有点喧宾夺主之意,所以决定单独用另一个系列来放置关于图形学的一些内容。另外,本系列的内容并不是按照某种顺序来编写的,而是想到哪些要点就介绍一些,算是带有科普性质的知识笔记。

笔者并非图形学专业人士,只是将目前理解到的一些内容整理成文,如有内容上的问题,还请读者指出。

本文内容主要为概念介绍,所以不会有代码产生。

三维世界基础

我们都知道,两点组成一直线,三线(三点)组成一平面。所以,假设在三维空间中有这样三个点:
(0, 1, 0)

(-1, 0, 0)

(1, 0, 0)
,通过简单的思考,我们知道它们可以组成一个没有厚度的三角形,当然,我们可以使用更多的点组成更多的面,来创造一个更加立体的物体:

010-things

对于一个三维空间的物体,我们一般分为两个阶段来“感知”它。

第一阶段是“创造”这个物体。我们首先在空间的某处定义好一些“顶点”;然后,通过将这些“顶点”两两直线相连,我们会得到一个物体的框架;最后,我们给框架上的每一面“糊”上一层“纸”,就得到了一个不透明的立体物体。

020-cube

第二阶段则是“观察”这物体。在第一阶段,我们仅仅是将一个三维物体创造了出来,它存在于空间的某处。为了让我们感知到它的存在,我们会用眼睛从某些角度去看它,或使用一台摄像机将它拍摄并呈现在一张照片上。但无论哪种方式,我们会发现我们都将一个空间中三维的物体“投射”一个“面”上,即将三维物体“降维”到了二维面上。

当然,将三维物体投影到二维平面上,一般有两种投影方式:正射投影、透视投影,二者最大的区别在于透视投影需要考虑近大远小的成像机制。例如,上图的立方体场景下,我们在站在z轴上俯看立方,正射投影和透视投影会出现不同的效果:

030-projection

从上图可以看出,透视投影明显是最符合我们通常认知的投影方式。对于三维世界有了基本的认识以后,让我们接下开始对图形学的一些内容进行介绍。

图形学的要素

在上节中,我们简单介绍了在现实的三维世界中如何构造并观察到一个三维物体。在计算机图形学中其实也并没有完全的跳出这个过程。在计算机图形学中同样会有点、线、面,以及最终要呈现到屏幕上的“投影”。我们将从本节开始,深入浅出计算机图形学的一些重要概念以及它们的核心作用。

顶点 vertex

什么是顶点?读者初识“顶点”这个词的时候,可能会觉得“顶点”就是上面三维物体在构建过程的第一步中,我们进行定义的各个“点”。这样的理解对但不完全对,因为几何物体上的各个点是
狭义
上的顶点,它们仅仅表示三维空间中的一些位置。

040-only-points

而计算机图形学中的顶点vertex,则是一个包含有更多内容的数据合集,包括不限于该点的:位置坐标、颜色信息等(为了不然读者产生过多的疑惑,我们先只提较为理解两个属性)。也就说,几何中的顶点几乎等同于位置坐标,而计算机图形学中的顶点,除了位置坐标以外,还可以包含该点的颜色等其他信息。

050-vertex-more-info

读者一定要记住,从现在开始,只要谈到了“顶点”,都指的是包含位置、颜色以及其他额外信息的整个顶点数据,如果仅仅是描述位置,我们一定使用全称:“顶点位置”。

顶点包含颜色数据的意义是什么?笔者在第一次接触计算机图形学的时候,能够很自然的理解顶点包含位置坐标数据,但是对于包含颜色信息(甚至是法线信息等)百思不得其解,直到后来了解的越来越多以后,才渐渐的理解了这其中的奥妙。当然,这里笔者先卖个关子,等到后续的图元、片元介绍完成以后,再回过头来就能够很好的理解了。

图元 primitive

在计算机图形学中,图元(Primitives)是构成图像的基本元素,它们是图形渲染过程中最基础的几何形状。图元可以是点、线、多边形(如三角形或四边形)等。

图元装配

要得到图元,我们需要将上一小节介绍的“顶点”作为输入,进行一系列的操作,才能得到图元,这个过程就叫做
图元装配
。让我们用更加形象的例子来理解这个过程。

假设现在有三个点:
(0, 1)

(-1, 0)
以及
(1, 0)
。这三个点可以组成什么图形呢?需要我们用不同的方式来看:

  1. 点方式(Points):每个顶点都作为一个单独的点来渲染。
  2. 线方式(Line):连续的两个顶点形成一条线段。
  3. 三角形方式(Triangles):每三个顶点组成一个三角形。

下图是上述三种方式下,结合上面三个点得到图形结果:

060-point-line-face

图元装配还远远不止上述提到的三种方式,根据顶点数据的不同,还有其他形式的装配方式

所以,读者现在应该能够理解图元装配的核心逻辑是,将n个顶点通过某种图元装配的方式来得到最终的图形(不同的装配方式往往伴随着不同的算法逻辑)。

当然,在生成图元的时候,还包含了一些操作,不过为了帮助读者更好的理解,我们暂时放一放,后面统一讲解。

片元 fragment

在介绍片元前,我们需要先提到一个操作概念:光栅化。光栅化是将几何数据经过一系列变换后转换为像素,并呈现在显示设备上的过程。我们常见的显示设备是由物理像素点按照一定的宽高值组成一块完整的屏幕。也就是说,屏幕上的像素点不是“连续”的。然而,我们的图像是“连续”的,这就意味着对于几何图形,一条线,特别是非水平非垂直的线,这条线上的每一点我们总是需要通过一定的近似处理,来得到其在屏幕上的物理像素的坐标。

我们以呈现一个三角形为例。假设现在有下图三角形,从几何的角度来看,它的斜边没有任何的异常:

070-a-triangle
然而,我们的物理设备像素是整数值且有限,假设有一块分辨率为 20x20 的屏幕,为了呈现斜边,我们可能需要按照如下的形式来找到对应的像素点填色:

080-rasterization-triangle

注意看,笔者在几何三角形上选取了1个点,几何坐标为
(0.5, 0.5)
。在 20x20 的屏幕上,其屏幕坐标为
(10, 10)

光栅化逻辑就是对于几何图形上每一个“点”,在屏幕设备上找到对应的像素点的过程。对于光栅化的实现,就不在本文的讨论范围内了,对于这块感兴趣的同学可以自行查阅相关资料进行深入研究。

简单了解完光栅化后,让我们回到本节的核心:片元fragment。片元实际上就是图元primitive经过光栅化处理后的一个或多个像素大小的样本。在这有两点值得注意:

  1. 尽管叫做
    片元
    ,但通常指的是一个或少许多个像素大小的单位。也就是说,图元是一个整体几何图形,经过光栅化会被分解为多个片元。
  2. 光栅化后得到的片元只是
    接近
    像素点,但并不完全等于像素点。片元是与像素相关的、待处理的数据集合,包括颜色、深度、纹理坐标等信息(深度和纹理坐标等先简单理解为一些额外数据,后续会讲解)。这个地方有点类似于前面我们说的,图形学中的顶点,并不是简单的几何的顶点,而是包含有顶点位置、颜色信息等的一个数据集合。

片元并非像素点,它只是接近像素点,所以通常来说,我们还会有一个步骤来对片元进行进一步的处理,好让它最终转换为屏幕上的像素点来进行呈现(此时基本就是带有rgba颜色的点了)。

至此,我们简单了解了图形学中的三个要素:顶点、图元与片元。本来,接下来的内容应该介绍渲染管线了。但是笔者思考以后始终觉得,直接搬出一些概念,还是不够直观,初学者很容易被劝退。所以在介绍渲染管线之前,笔者决定先介绍一下在图形学中的空间变换。

图形学中的空间变换

模型空间与世界空间

假设我们制作了一个边长为2的立方体,如下图所示:

090-simple-cube-model

此时,这个立方体我们是在当前坐标系中创建出来的。所以它的各个点的位置就如上图所示(例如图中标识的三个点:
(1, 0, 1)

(1, 1, -1)
以及
(-1, 1, 1)
等)。

随后,我们将这个立方体放置到一个“世界”场景中,在此之前,“世界”场景中已经有了一个球体:

100-a-ball-in-world

为了不让他们重叠,我们将这个立方体先将边长从原来的2缩小到1个单位,然后放置到如下位置:

110-cube-and-ball

注意看,此时我们的立方体的坐标在此刻与球体共存的世界中的ABC三个点的坐标就不再是原有的坐标,而是在这个“世界”下的坐标(
A(3, 0.5, -0.5)

B(3, 0, 0.5)

C(2, 1, 0.5)
),这个过程,其实就是将“模型空间”转换到“世界空间”(的坐标)的过程。

“模型空间”的意义是每个三维物体本身在自己的一个空间坐标系下(又叫“局部坐标空间”),我们去定义这个物体的三维数据的时候,不依赖于外部,而是一个纯粹的当前物体的一个空间。

然而,我们将一个物体创造好以后,我们
一般
都需要将它放置到一个和其他的物体在一起的一个地方,组成一个场景,使之更有意义,而这个地方就是“世界空间”,在这个过程中,我们一般会对某个单独的模型物体进行旋转、缩放等操作,以便让它更加协调的存在于这个“世界空间”中。

观察空间

当得到世界空间下的坐标的时候,我们会进一步将其变换为“观察空间”。介绍“观察空间”前,我们需要先引入一个角色:摄像机。既然我们已经将“世界”准备好了(例如上面的一个立方体加上一个球体的场景),我们总是需要“观察”它,不然就没有意义了。因此,我们就会需要一个类似“摄像机”的角色。这个摄像机会包含有3个要素:1、摄像机的位置;2、摄像机看向的目标方向;3、摄像机的向上方法。对于摄像机的位置和看向的目标方向容易理解,对于摄像机的向上方向,用下面的示例应该也很好理解了:

120-camera-direction

上图中,我们首先在空间的某处放置一个摄像机,让它“看向”一棵树,我们使用蓝色向量表示出这个方向;通过这个蓝色的向量的方向和摄像机所在的位置,我们可以确定唯一一个让蓝色向量垂直的平面。在这个平面上, 我们可以找到无限组相互垂直的向量(例如上图左右两个摄像机的的红、绿色向量,我们可以绕着蓝色向量方向转动,还能得到更多的红、绿向量),其中,我们会把红色的向量定义为摄像机的右向量,那么垂直于红、蓝向量平面的绿向量就是向上的向量。摄像机向上方向的不同,会导致图像摄像机拍摄的物体的向上方向不同。

注意,这里的红绿蓝向量,其实和一些教程(例如《[摄像机 - LearnOpenGL CN (learnopengl-cn.github.io)](
https://learnopengl-cn.github.io/01
Getting started/09 Camera/)》)是不太一致的。本文只是从现实出发,使用一种容易理解的方式来介绍概念,并不是完全的考虑计算层面的事情。

有了确定的摄像机以后,我们需要进行这样的操作。将摄像机和整个世界做一次整体的位移操作,让摄像机移动到原点,观察方向与Z轴重合,上方向与Y轴重合,同时让“世界”中的物体保持与摄像机相对不变:

130-camera-transform

140-camera-transform-animation

在完成移动以后,原先的物体的坐标在摄像机处于原点的坐标空间下有了新的坐标位置(例如,原本我们的球体最顶部的点坐标是
(0, 2, 0)
,经过将世界空间转变为观察空间,就成了
(0, 2, -2)
),而这个过程就是“世界空间”转变为“观察空间”,摄像机处于原点的坐标空间就是“观察空间”。

为什么有观察空间?因为将摄像机放到原点的过程后,对于后续的观察投影处理很方便。

经过一系列的操作,我们已经将一个2x2x2的立方体,从模型空间变换到了观察空间:

150-point-transform-flow

注意看上图立方体A点的左边经过一系列变换的结果

在观察空间的基础上(此时摄像机就在观察空间的原点),我们就会开始进行投影处理。正如一开始介绍的,投影一般来说分为两种:1)正射投影;2)透视投影。

对于上面我们创建的球体和立方体,摄像机放在原点,分别使用正摄像投影和透视投影的效果大致如下:

160-projection

容易理解的是,经过投影以后,我们将三维立体的物体转变为了二维图像。原先三维空间中任意一个点,都“投射”到了二维空间上。如果我们将这个二维空间视为我们的显示器屏幕。那么很显然,我们需要将“观察空间”中某一个三维坐标点通过一定的方式的计算变换,得到在屏幕上某个具体位置的像素。

170-projection-to-screen

对于上图,原本在观察空间中的点
A
的坐标,会通过一定的上下文(摄像机的距离、FOV视野等)通过一定的数据计算,来得到
A'
,而这个
A'
是屏幕上的某个x、y均为整数的像素坐标。

写在最后

本文就大致介绍关于图形学中的一些基本要素,以及空间变换。在后面的文章中,会逐步介绍计算机图形学中的一些内容,例如渲染管线等。