0%

书山有路勤为径,学海无涯苦作舟

日期 书名 内容
2025年3月12日 《C++ Primer Plus》 对象和类 10.3~10.6
2025年3月19日 《Python编程从入门到实践》 入门内容基本看完
2025年4月1日 《深入了解计算机系统》(俗称CSAPP) (第一章)计算机系统漫游
2025年4月16日 《C++ Primer Plus》 内存模型和命名空间
2025年4月18日 《C++ Primer Plus》 对象和类(重读)

上一篇中学习了基本的本地的版本控制,现在进一步的学习Git的使用。

远程仓库

现在我们需要找一个网站用来为我们提供Git仓库托管服务,这个网站叫做Github,这个网站为我们提供了Git仓库的托管服务,不过我们首先需要注册一个账号,这里我已经搞完了。

本地的Git仓库和Github仓库之间的传输时通过SSH设置的,所以我们需要设置:

创建SSH Key:这个我在本机上已经搞过了,这次在WSL上也搞一次,输入指令

1
$ ssh-keygen -t rsa -C "youremail@example.com"

然后你就会看到这个界面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ ssh-keygen -t rsa -C "3280661240@qq.com"
Generating public/private rsa key pair.
Enter file in which to save the key (/home/ylin/.ssh/id_rsa):
Created directory '/home/ylin/.ssh'.
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /home/ylin/.ssh/id_rsa
Your public key has been saved in /home/ylin/.ssh/id_rsa.pub
The key fingerprint is:
SHA256:7Y9JTYk3YxznFF9ODtZ5xak0GKB5t4Llw1eK/HRabBw 3280661240@qq.com
The key's randomart image is:
+---[RSA 3072]----+
| ...o +oB|
| o . + X=|
| o o .oE= =|
| B.oo*B. |
| .SBo=O*. |
| .*==o |
| oo. |
| . + |
| o . |
+----[SHA256]-----+

Enter passphrase处可以输入你的密码(用于生成私钥)

配置公钥文件:获取公钥的内容

1
2
$ cat id_rsa.pub
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCg9NIo/g5YB3ghujWZyhqN4XL4Zu4lowZh66OefI0jwdqJ6LRPcxQfmM7zw6EKK6HZID0OtrkfP7Rohcwo4D1rZi4R6I6V8hXSsM3OxP8NlDo4OvG6sheJS4SWNF5ajjjAzZaFYxPvtR7zvVaw0640w6iAOKlc55hNvFf35a647W0o3OzCK/B+/knduY4WYdn7ApBBPM8Ktwf4BHVS5098PpJeu8w4SZIMe59O4iRbpICrnmeKaPkf/U3bLqvhOAwFkyW7W/ql6B7uh7hzbPmTbKNvT12Zykk8JcbJv5Wd5PVVULfFNbmVqckrdJ+xNs6RqVfUFG0cuhI7b16WGcoNWnCW...

然后复制内容到这里:

image.png
image.png

现在我们就添加成功啦。

SSH Key的作用是,Github需要识别处你推送的提交是你的推送的,而不是别人冒充的。同时由于RSA加密的特性,公钥是可以公开的,但是私钥是由自己保管的,从而确保了加密的安全性。在此基础上,我们可以开始远程仓库的使用了。

添加远程库

现在我们在本地已经有了一个版本库,我们再到Github创建一个仓库,让这两个仓库进行远程同步。这样Github上的仓库既可以作为备份,也可以让其他人通过仓库来协作。

首先在Github上面创建一个新的仓库:

image.png

一个Github仓库就这样建好了,现在我们将其和我们的本地仓库关联起来。根据Github下面的提示,我们在本地的仓库里面运行这些命令:

1
$ git remote add origin git@github.com:Ylin07/Learn_Git.git

添加后,远程库的名字就是origin,这是Git的默认叫法。下一步,我们将本地库的所有内容推送到远程库上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ git push -u origin master
The authenticity of host 'github.com (140.82.112.4)' can't be established.
ED25519 key fingerprint is SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added 'github.com' (ED25519) to the list of known hosts.
Enter passphrase for key '/home/ylin/.ssh/id_rsa':
------------------------------------
Enumerating objects: 22, done.
Counting objects: 100% (22/22), done.
Delta compression using up to 32 threads
Compressing objects: 100% (14/14), done.
Writing objects: 100% (22/22), 1.72 KiB | 1.72 MiB/s, done.
Total 22 (delta 3), reused 0 (delta 0), pack-reused 0
remote: Resolving deltas: 100% (3/3), done.
To github.com:Ylin07/Learn_Git.git
* [new branch] master -> master
branch 'master' set up to track 'origin/master'.

其中由于,这是第一次关联,我们需要验证公钥指纹,然后将身份添加到~/.ssh/known_hosts中,同时需要输入密钥验证,这里我用分割线把这一部分区分出来。下面则是我们将本地仓库推送上去的信息。

由于远程库是空的,我们第一次推送master分支,使用了-u参数,Git不但会把本地的master分支推送上去,还会把本地的master和远程的master分支关联起来,这样在之后的推送和拉去就可以简化命令。

然后我们就可以在Github页面中看到远程库的内容和本地是一样的

image.png

从现在开始,只要本地作了提交,就可以通过命令:

1
$ git push origin master

把本地的master分支的最新修改推送到GIthub,现在我们就有了完整的分布式版本库。

删除远程库

如果添加的时候地址写错了,或者是想删除远程库,可以使用git remote rm <name>命令。使用前,我们先使用git remote -v来查看远程库的信息:

1
2
3
$ git remote -v
origin git@github.com:Ylin07/Learn_Git.git (fetch)
origin git@github.com:Ylin07/Learn_Git.git (push)

然后根据名字删除,比如删除origin:

1
$ git remote rm origin

此处的删除实际上是解除了本地和远程的关联状态,并不是删除了远程库。远程库本身并没有改动,如果想要删除远程库,应该到Github后台删除。

克隆远程仓库

上次我们讲了现有本地库,再有远程库的时候,如何关联远程库。现在我们从头开始,假如我们从远程库开始克隆该怎么做呢?

我们把这个网址的项目给clone下来8086

1
2
3
4
5
6
7
8
9
$ git clone git@github.com:Rexicon226/8086.git
Cloning into '8086'...
Enter passphrase for key '/home/ylin/.ssh/id_rsa':
remote: Enumerating objects: 31, done.
remote: Counting objects: 100% (31/31), done.
remote: Compressing objects: 100% (26/26), done.
remote: Total 31 (delta 6), reused 27 (delta 4), pack-reused 0 (from 0)
Receiving objects: 100% (31/31), 6.61 KiB | 1.65 MiB/s, done.
Resolving deltas: 100% (6/6), done.

OK,然后看看文件夹,已经被远程库的内容已经被拉下来了:

1
2
3
$ cd 8086
$ ls
bootloader CMakeLists.txt emulator README.md

当然我们还可以使用其他的方法,比如https协议,只不过这个对网络环境有一定的要求。

1
git clone https://github.com/Rexicon226/8086.git

现在我们就掌握了对于远程仓库的基本操作啦。

分支管理

当你和别人共同开发一个项目时,你们可以各自创建一个分支,不同的分支拥有自己的进度,互不打扰。再各自的项目完成之后,再将分支合并,从而实现同时开发的效果,这就是Git的分支管理功能。

创建与合并分支

在版本回退中我们知道,Git把提交串成一条时间线,这个时间线就是一个分支。当目前为止,我们只有一个分支,这个分支就叫主分支master,而其中的HEAD指针并不直接指向提交的,它指向的是master,而master指向的是提交,所以我们说HEAD指向的是当前的分支。

一开始的时候,master分支是一条线,Git用master指向最新的提交,再用HEAD指向master,就能确定当前分支,以及当前分支的提交点:

1
2
3
       HEAD --> master
|
[ ]-->[ ]-->[ ]

每次提交master分支就向前移动一步,这样随这不断的提交,master分支也会越来越长。

当我们创建新的分支new时,Git新建了一个指针叫new,指向master相同的提交,再把HEAD指向new,就表示当前分支在new上:

1
2
3
4
5
               master
|
[ ]-->[ ]-->[ ]
|
HEAD --> new

由此可以看出,Git创建一个分支很快,实际上就是增加了一个new指针,然后改变一下HEAD的指向

不过接下来,对于工作区的修改与提交就是针对new了,比如提交一次之后会变成这样:

1
2
3
4
5
                master
|
[ ]-->[ ]-->[ ]-->[ ]
|
HEAD --> new

假如我们在分支new上的工作完成了,就可以把new合并到master上。Git怎么合并呢,实际上就是将master移动到和new指向相同的版本上,然后将HEAD指向master

1
2
3
4
5
               HEAD --> master
|
[ ]-->[ ]-->[ ]-->[ ]
|
new

合并完成之后,甚至可以删除new分支,我们直接将其new指针删除既可,这样就只剩下一个mater分支:

1
2
3
               HEAD --> master
|
[ ]-->[ ]-->[ ]-->[ ]

这样相当于用分支的功能完成了提交,这样更加安全。

现在我们来进行尝试:

首先创建一个new分支,然后切换过去

1
2
$ git checkout -b new
Switched to a new branch 'new'

git checkout命令加上-b参数表示创建并切换,相当于以下两个命令的组合

1
2
3
4
5
6
$ git branch new
$ git branch
* master
new
$ git checkout new
Switched to branch 'new'

git branch命令会列出所有的分支,当前分支前会有一个*号。然后我们在new分支上修改后正常提交:

1
2
3
4
$ git add readme.txt
$ git commit -m "branch test"
[new 4c8096e] branch test
1 file changed, 1 insertion(+)

现在dev的分支工作完成,我们切换回master分支,然后再查看readme.txt

1
2
$ git checkout master
Switched to branch 'master'

然后我们打开readme.txt发现原来的修改怎么不见了。因为刚刚提交的修改在new分支上,此时master分支的提交点并没有变:

1
2
3
4
5
       HEAD --> master
|
[ ]-->[ ]-->[ ]-->[ ]
|
new

现在我们将new分支的工作成果合并到master分支上:

1
2
3
4
5
$ git merge new
Updating f940b04..4c8096e
Fast-forward
readme.txt | 1 +
1 file changed, 1 insertion(+)

git merge命令用于合并指定分支到当前分支。合并后,再查看readme.txt的内容,可以看到现在和最新提交是一样的了。

注意到上面的Fast-forward信息,它的意思是这次合并是“快进模式”,也就是把master指向dev的当前提交,所以很快

合并之后,我们将new分支删除,并查看branch,只剩下了master

1
2
3
4
$ git branch -d new
Deleted branch new (was 4c8096e).
$ git branch
* master

由于创建,合并和删除分支非常快,所以Git更加鼓励使用分支完成任务,然后再删除分支,这样比直接在master上工作的效果是一样的,但是过程更加的安全。

switch

切换分支,除了使用git checkout <branch>,还可以使用switch来实现:

  • 创建并切换到新得new分支,可以用:git switch -c new
  • 直接切换到已有得master分支,可以用:git switch master

解决冲突

有时候合并也会遇到各种冲突,现在我们手动制造一个冲突,我们创建一个新的分支n1:

1
2
$ git switch -c n1
Switched to a new branch 'n1'

最后一行加个01234,然后提交修改

1
2
3
4
$ git add readme.txt
$ git commit -m "01234"
[n1 6d5f5d1] 01234
1 file changed, 1 insertion(+)

然后切换回master分支,然后对readme.txt做不一样的修改,并提交:

1
2
3
4
$ git add readme.txt
$ git commit -m "56789"
[master 38fe2c0] 56789
1 file changed, 1 insertion(+)

现在mastern1分支各自都分别有新的提交:

1
2
3
4
5
                  +-->[   ] <-- master <-- HEAD
|
[ ]-->[ ]-->[ ]
|
+-->[ ] <-- n1

这种情况下,Git没办法快速合并,只能视图把各自的修改合并起来:

1
2
3
4
$ git merge n1
Auto-merging readme.txt
CONFLICT (content): Merge conflict in readme.txt
Automatic merge failed; fix conflicts and then commit the result.

结果提示了冲突,我们需要手动解决冲突后再提交,我们可以用git status告诉我们冲突的文件:

1
2
3
4
5
6
7
8
9
10
11
$ git status
On branch master
You have unmerged paths.
(fix conflicts and run "git commit")
(use "git merge --abort" to abort the merge)

Unmerged paths:
(use "git add <file>..." to mark resolution)
both modified: readme.txt

no changes added to commit (use "git add" and/or "git commit -a")

我们打开readme.txt看看:

1
2
3
4
5
6
7
8
9
10
11
12
Hello Git!!!
I love you!
Sorry I love her more.
Please forgive me.
The first modify.
The second modify.
Test new branch.
<<<<<<< HEAD
56789
=======
01234
>>>>>>> n1

Git 用<<<<<<<,=======,>>>>>>>标记出不同分支的内容,我们修改后再保存

我们修改之后再保存:

1
2
3
4
5
6
7
8
Hello Git!!!
I love you!
Sorry I love her more.
Please forgive me.
The first modify.
The second modify.
Test new branch.
00000

再提交:

1
2
3
$ git add readme.txt
$ git commit -m "conflict mixed"
[master f17b017] conflict mixed

现在我们的版本库变成了这样:

1
2
3
4
5
6
                  +-->[   ]-->[   ] <-- master <-- HEAD
| ^
[ ]-->[ ]-->[ ] |
| |
n1 --> +-->[ ] ———— +

我们可以用带参数的got log可视化的看到我们分支的合并情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

$ git log --graph --pretty=oneline --abbrev-commit
* f17b017 (HEAD -> master) conflict mixed
|\
| * 6d5f5d1 (n1) 01234
* | 38fe2c0 56789
|/
* 4c8096e branch test
* f940b04 A test
* 4c16e7a The last patch
* c34bcbc git tracks change

* 6801700 add LICENSE & Pls
* da91da7 add sorry
* 20deae4 add my love
* 1428371 wrote a read file

解释以下标签的意思:

  • --graph:以字符可视化的方式打印分支流程
  • --pretty=oneline:以一行压缩打印,不显示id之外的信息
  • --abbrev-commit:简略id信息,只显示一部分

合并之后,我们删除n1分支:

1
2
3
4
$ git branch -d n1
Deleted branch n1 (was 6d5f5d1).
$ git branch
* master

分支管理策略

通常合并分支时,Git会优先使用Fast forward的模式,但这种模式下,删除分支,会丢失分支信息

如果我们要强制禁用Fast forward模式,Git就会在merge时生成一个新的commit,这样就从分支历史上可以看出分支信息,下面我们尝试一个--no-ff方法的git merge

首先创建一个分支,并修改内容,然后提交修改:

1
2
3
4
5
6
7
$ git switch -c dev
Switched to a new branch 'dev'
$ ni readme.txt
$ git add readme.txt
$ git commit -m "try new merge"
[dev f769ab7] try new merge
1 file changed, 1 insertion(+), 1 deletion(-)

然后我们切换回master并使用--no-ff参数,以禁用Fast forward:

1
2
3
4
5
6
$ git switch master
Switched to branch 'master'
$ git merge --no-ff -m "merge with no-ff" dev
Merge made by the 'ort' strategy.
readme.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)

因为本次合并会产生新的commit,所以加上-m参数,把commit描述加进去。然后用git log查看历史:

1
2
3
4
5
6
7
8
$ git log --graph --pretty=oneline --abbrev-com
mit
* 806dc97 (HEAD -> master) merge with no-ff
|\
| * f769ab7 (dev) try new merge
|/
* f17b017 conflict mixed
...

可以看到不用Fast forward模式,merge后就像这样:

1
2
3
4
5
                  +-->[   ] <-- master <-- HEAD
| ^
[ ]-->[ ]-->[ ] |
| |
+-->[ ] <-- n1

分支策略

在实际开发种,我们应该明确几个基本原则,进行分支管理:

  • 首先master分支应该稳定的,仅用来发布新版本,不能在上面干活
  • 平时应该在dev分支上干活,只有特定版本可以发布时,我们再将devmaster分支进行合并
  • 每个人又自己的分支,当完成特定功能时,再向dev分支合并

因此,团队协作更像是这样的:

image.png

朋友圈里看到同学用Git,觉得图形化还是很帅的。加上最近刚好有需求,所以就来系统的学习一下git的使用。

这里参考的教程是:简介 - Git教程 - 廖雪峰的官方网站

先从git在本地的简单使用开始学习一下吧,明天再接着讲讲git的远程使用

Git

git是一款分布式的版本管理器。

分布式的意思就是一个每个开发者人的工作区就是一个完整的版本库。你的修改,和分支都可以再本地仓库里面完成,然后再推送到远程仓库中。不同的开发者之间通过向远程仓库push修改,或者从远程仓库pull操作,从而实现同步代码。

初见Git

这里我选择在WSL中使用git,可以通过sudo apt-get install git下载git

然后我们需要对我们git进行配置,我们需要初始化自己的信息:

1
2
$ git config --global user.name "Your Name"
$ git config --global user.email "email@example.com"

这里的--global参数指的是对本机器的所有仓库都使用这个信息,当然你也可以在不同的仓库中使用不同的用户信息。

版本库

我们刚刚所说的仓库实际上就是版本库(Repository),之后我们会频繁的看到这个单词。在这里,你可以讲仓库理解成一个目录,里面的内容被git管理起来(被其跟踪修改,记录历史,或者在之后用来”还原”)

首先我们创建一个版本库作为我们的练习:

1
2
3
4
$ mkdir learn_git
$ cd learn_git/
$ pwd
/home/ylin/Program/learn_git

然后使用git init对我们的仓库进行初始化:

1
2
$ git init
Initialized empty Git repository in /home/ylin/Program/learn_git/.git/

然后我们可以在当前目录下找到一个.git目录,不过它是隐藏的,所以我们使用ls -ah来找到它,这个目录用来跟踪管理版本库的。

向版本库中添加文件

这里我们需要搞清楚一件事,就是所有的版本控制器,只能管理文本文件,无法管理二进制文件。你可以管理文本文件,哪里被修改了,哪里增加了,但是你不能追踪一张图片被做了什么修改。

现在我们向我们的鹅目录下写一个readme.txt文件:

1
Hello Git!

接下来我们将其提交到我们的仓库,需要以下过程:

  • 将文件添加到仓库:git add readme.txt
  • 将文件提交到仓库:git commit -m "..."(-m 后面用来添加改动记录,也可以不加)

这个是加入仓库后的结果:

1
2
3
[master (root-commit) 1428371] wrote a read file
1 file changed, 1 insertion(+)
create mode 100644 readme.txt

其中1 file changed是因为我们添加了一个文件;1 insertion(+)是因为我们添加了一行内容

这里的git addgit commit之所以分开的原因后面会解释。现在我们只需要知道,add可以添加很多次很多内容,commit则是将添加的内容全部提交上去

版本管理

我们刚刚成功的添加并提交了readme.txt文件,现在我们继续工作,向其中添加内容,改成:

1
2
Hello Git!!!
I love you!

现在运行git status命令看看结果:

1
2
3
4
5
6
7
8
$ git status
On branch master
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: readme.txt

no changes added to commit (use "git add" and/or "git commit -a")

通过这个命令我们可以时刻掌握仓库当前的状态,例如上面的信息告诉我们,readme.txt的内容被修改了,但是我们还没有提交该修改。

可是我们只知道文件被修改了,但我们并不知道哪些地方被修改了,所以我们使用git diff来查看一下:

1
2
3
4
5
6
7
8
9
$ git diff readme.txt
diff --git a/readme.txt b/readme.txt
index 106287c..94d4f0e 100644
--- a/readme.txt
+++ b/readme.txt
@@ -1 +1,2 @@
-Hello Git!
+Hello Git!!!
+I love you!

顾名思义这个指令是用来查看difference的,显示的格式是Unix通用的diff格式,注意其中的@@ -1 +1,2 @@-后面的两个值分别是原始文件的差异起始行和差异行数,+后面的两个值分别是修改文件的差异起始行和差异行数。这些信息常用于定位文件的修改内容和修改范围。知道文件哪些地方被修改之后,我们再次将我们的readme.txt提交到我们的仓库中:

1
2
3
4
5
6
$ git status
On branch master
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
modified: readme.txt

我们再次运行git status看看当前仓库的状态,我们看到我们将要提交的修改的内容有readme.txt

现在我们将其提交git commit:

1
2
3
$ git commit -m "add my love"
[master 20deae4] add my love
1 file changed, 2 insertions(+), 1 deletion(-)

然后再看看仓库,git status:

1
2
3
$ git status
On branch master
nothing to commit, working tree clean

Git告诉我们,当前没有要提交的内容,且工作目录是干净的

版本回退

我现在已经了解了怎么修改,我们尝试对其进行再一次的修改和提交:

1
2
3
Hello Git!!!
I love you!
Sorry I love her more.

然后再次提交:

1
2
3
4
$ git add readme.txt
$ git commit -m "add sorry"
[master da91da7] add sorry
1 file changed, 2 insertions(+), 1 deletion(-)

这个过程在实际的项目中会重复很多次,每当文件修改到一定的程度我们将其commit,即保存一个快照。一但我们的文件出现了错误,我们就可以用Git来回到之前的版本,接下来我们将演示这个过程。

现在我们的Git仓库里一共有三个版本,但是我们并不记得每次改动了哪些内容。我们现在可以使用git log命令来查看我们的版本控制系统中的历史记录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ git log
commit da91da7e64267eb6f293ffc8b0bde18626777d58 (HEAD -> master)
Author: Ylin07 <3280661240@qq.com>
Date: Fri Apr 18 23:22:35 2025 +0800

add sorry

commit 20deae4e44bc6786110d3beeda5c86ba55b75f23
Author: Ylin07 <3280661240@qq.com>
Date: Fri Apr 18 23:18:16 2025 +0800

add my love

commit 1428371d4f5f753c24e22e4311f6c11b3ff0656b
Author: Ylin07 <3280661240@qq.com>
Date: Fri Apr 18 22:47:36 2025 +0800

wrote a read file

git log命令显示从最近到最远的提交日志,如果你认为输出的信息太多,可以试试git log --pretty=oneline参数:

1
2
3
4
$ git log --pretty=oneline
da91da7e64267eb6f293ffc8b0bde18626777d58 (HEAD -> master) add sorry
20deae4e44bc6786110d3beeda5c86ba55b75f23 add my love
1428371d4f5f753c24e22e4311f6c11b3ff0656b wrote a read file

然后你会看到前面一大堆看不懂的东西,这个是commit id,每一次提交,都有对应的id,原理是SHA1计算

现在我们想要把readme.txt回到上一个版本,怎么办?首先需要知道,当前是什么版本(就是最新提交的14283…),当前的版本是HEAD,上一个版本是HEAD^,上上个版本就是HEAD^^,那么上n个版本就是HEAD~n

现在哦我们将当前版本add sorry回退到上一个版本add love,我们使用git reset:

1
2
$ git reset --hard HEAD^
HEAD is now at 20deae4 add my love

--hard参数会回退到上个版本的已提交状态,--soft会回退到上个版本的未提交状态,--mixed 会回退到上个版本已添加但未提交的状态,这里我们使用--hard

我们可以继续回退,但是我们现在也要面临一个问题,我们没办法回去了,有办法吗?有的兄弟,有的。我们先看下log

1
2
3
$ git log --pretty=oneline
20deae4e44bc6786110d3beeda5c86ba55b75f23 (HEAD -> master) add my love
1428371d4f5f753c24e22e4311f6c11b3ff0656b wrote a read file

发现之前的commit id没了,这咋办呢?我们使用git reflog查看HEAD指针的历史记录:

1
2
3
4
5
$ git reflog
20deae4 (HEAD -> master) HEAD@{0}: reset: moving to HEAD^
da91da7 HEAD@{1}: commit: add sorry
20deae4 (HEAD -> master) HEAD@{2}: commit: add my love
1428371 HEAD@{3}: commit (initial): wrote a read file

我们可以看到da91da7是先前版本的commmit id,我们使用git reset返回这个版本:

1
2
$ git reset --hard da91da7
HEAD is now at da91da7 add sorry

我们成功的回到了这个版本(这里版本号不需要写全,Git会自己去查找),就这样我们实现了git的版本控制

Git版本回退的速度特别快,你可能会好奇这是为啥,这是因为Git内部有一个指向当前版本的指针HEAD,当你回退版本的时候,仅仅只是把HEAD指向了前一个版本。然后顺便把工作区的文件更新了。

工作区和暂存区

这个概念是Git特有的一个概念,我们先对其进行解释:

  • 工作区:实际上就是再电脑中能看到的目录
  • 版本库:工作区中有一个隐藏的目录.git,这个不算工作区,而是Git的版本库。其中版本库里有很多东西,其中一个比较重要的内容就是stage(也叫index),也就是暂存区。以及Git为我们自动创建的一个分支master,以及指向master的一个指针HEAD
image.png

前面我们讲到,将文件加入Git版本库中的时候,是分两步执行的,现在我们可以解释了 :

  • git add 实际上就是将文件添加到暂缓区
  • git commit 实际上就是将暂存区中的所有内容提交到当前分支中

创建版本库的时候,Git为我们创建了一个mater分支,也就是当前的git commit都是向master分支上提交更改

现在,我们带着这个思考再次进行这个过程,我们修改一下readme.txt并添加一个LICENSE文件:

1
2
3
4
5
6
7
8
9
10
11
12
$ git status
On branch master
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: readme.txt

Untracked files:
(use "git add <file>..." to include in what will be committed)
LICENSE

no changes added to commit (use "git add" and/or "git commit -a")

我们可以看到,readme.txt被改动的信息,还有LICENSE被告知没有被添加过。我们用git add添加后再次查看:

1
2
3
4
5
6
$ git status
On branch master
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
new file: LICENSE
modified: readme.txt

现在我们的暂存区变成了这样:

image.png

然后再将其提交,并查看status状态:

1
2
3
4
5
6
7
$ git commit -m "add LICENSE & Pls"
[master 6801700] add LICENSE & Pls
2 files changed, 2 insertions(+)
create mode 100644 LICENSE
$ git status
On branch master
nothing to commit, working tree clean

现在的我们的版本库变成了这样,暂存区没有内容了:

image.png

管理修改

讲到这里就不得不介绍一下,Git相较于其他版本控制系统的优越之处。这是因为Git管理的不是那文件内容,而是对于文件的修改内容。这样极大的减少了系统的管理成本。

我们尝试以下过程,以验证我们提交的是修改,而不是文件内容:

第一次修改后,将其添加至缓冲区:

1
2
3
4
5
6
$ git add readme.txt
$ git status
On branch master
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
modified: readme.txt

然后我们再进行一次修改,接着我们直接提交到仓库:

1
2
3
4
5
6
7
8
9
10
11
$ git commit -m "git tracks change"
[master c34bcbc] git tracks change
1 file changed, 1 insertion(+)
$ git status
On branch master
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: readme.txt

no changes added to commit (use "git add" and/or "git commit -a")

但是我们发现我们的第二次修改并没有被提交,这就验证了我们的想法。我们提交的是第一次的修改,并不是最终的文件的样子,所以证明得到:提交的是修改而不是文件。

现在我们可以使用git diff HEAD -- readme.txt查看工作区和版本库中的最新版本的区别:

1
2
3
4
5
6
7
8
9
10
$ git diff HEAD -- readme.txt
diff --git a/readme.txt b/readme.txt
index 0394257..135beba 100644
--- a/readme.txt
+++ b/readme.txt
@@ -3,3 +3,4 @@ I love you!
Sorry I love her more.
Please forgive me.
The first modify.
+The second modify.

我们再次将其提交,git add –> git commit

撤销修改

撤销工作区的修改

有时候我们会犯错,如果错误发现的及时我们可以手动纠正回来,但是纠正前使用git status你可以看到以下信息:

1
2
3
4
5
6
7
8
$ git status
On branch master
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: readme.txt

no changes added to commit (use "git add" and/or "git commit -a")

这里提到可以使用git restore <file>...恢复工作区到暂存区的状态。

1
2
3
4
5
6
7
8
9
10
11
12
$ git status
On branch master
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: readme.txt

no changes added to commit (use "git add" and/or "git commit -a")
$ git restore readme.txt
$ git status
On branch master
nothing to commit, working tree clean

当然你也可以进一步使用参数git restore --source=HEAD~n -- readme.txt来指定将工作区恢复到哪次提交的版本

撤回暂存区修改

当然,我们还可能遇到另外一种情况,就是我们已经将错误git add到了暂存区,我们在commit之前发现了这个错误,我们该怎么修改呢?我们使用git status:

1
2
3
4
5
$ git status
On branch master
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
modified: readme.txt

Git 提醒我们可以使用git restore --staged <file>...来撤销最近一次的git add内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ git status
On branch master
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
modified: readme.txt

$ git restore --staged -- readme.txt
$ git status
On branch master
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: readme.txt

no changes added to commit (use "git add" and/or "git commit -a")
ylin@Ylin:~/Program/learn_git$

我们成功的撤销了暂存区的内容,接着我们再次撤销工作区的修改:

1
2
3
4
$ git restore readme.txt
$ git status
On branch master
nothing to commit, working tree clean

现在我们实现了对工作区和暂存区的撤回操作

版本库撤销

即版本回退,见上。

删除文件

在git中,删除也是一个修改操作,我们可以进行尝试。

我们向创建一个新文件test.txt并提交

1
2
3
4
5
$ git add test.txt
$ git commit -m "A test"
[master f940b04] A test
1 file changed, 1 insertion(+)
create mode 100644 test.txt

然后我将其从工作区中删除,再使用git status查看状态:

1
2
3
4
5
6
7
8
9
$ rm test.txt
$ git status
On branch master
Changes not staged for commit:
(use "git add/rm <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
deleted: test.txt

no changes added to commit (use "git add" and/or "git commit -a")

Git提醒我们这个文件被删除了,且指导我们可以使用git rm将其从版本库中删除,并且git commit:

1
2
3
4
5
6
$ git rm test.txt
rm 'test.txt'
$ git commit -m "remove git status"
[master 75303ec] remove git status
1 file changed, 1 deletion(-)
delete mode 100644 test.txt

现在文件就从版本库中删除了。

当然也有可能这个文件被你误删了,如果你上一次提交了这个文件的i需改,我们可以使用git restore --source=<commit_hash> <file_name>恢复到上一次提交的版本,不过还是会丢失你之后额外修改的内容。

至此,我们对于Git的基本使用就已经基本掌握了

今天是2025年4月17号,我打算从今天开始我未来的每月总结。

其实月中也挺好的,先忙半个月,然后看看有没有效果。然后再指正接下来半个月的要做什么。

我也不知道应该反省什么,我的上个月挺糟糕的。月初还算高兴吧,后面我女朋友的寝室出了点事情,她的室友意外去世了。我女朋友因为这个心理状态也不是很好,接下来的半个月,我一直在陪她。中途我和室友出去玩了一趟,从宣城,到马鞍山,再到南京。回来之后,我的心情挺好的,我的女朋友心情也好受了很多。但是后面她突然开始一直发烧,白天低烧,晚上高烧,一直到清明节之后。这段时间过的挺煎熬的,我自己也很多事情,很多时候我没有很好的照顾到她。她也很理解我。

然后再是这个月,我刚结束了图形学的基本学习。然后休息了两天,一直到现在,我觉得我好累,有点迷茫了。现阶段要学习的和要处理的东西特别多,所以我打算停下来缕一缕。

接下来打算学习的内容比较多,现在不知道学什么好,可以大致的列一下:

  • 计算机系统深入学习(CSAPP阅读,PWN的深入理解)
  • 巩固语言体系(C++继续学习,尝试学习Go)
  • 算法学习(C++深入学习,算法知识学习)
  • 学校课程学习(高等数学,线性代数,大学物理,英语四级准备)
  • 项目学习(Linux程序调试器, 2D物理引擎)

我现在有点搞不清学习顺序了,都做的话是不太可能的,所以现在慢慢理一理。

其中算法学习可以拖延至暑假,学校课程学习可以放到五六月开始,项目学习可以在计算机学习之后的一段空余时间继续。嗯,然后C++必须尽快学完,这是项目练习和算法学习的基础。

这么看来优先是这样的:

  1. C++深入学习
  2. 计算机系统学习
  3. Go语言学习

然后这个月我打算上完蒋炎岩的操作系统课程,看看能不能写出一个简单的操作系统。暂时就这些吧,然后是开始准备上高数课,不然进度上有点跟不上,英语记得的话背点单词吧。

关于我的心情,最近也是好多了,最近开始玩泰拉瑞亚,慢慢玩吧,要是能通关就好了,不急呐。还有怪物猎人,记得的话就玩一下呗。

马上五一劳动节,打算没事就学习,然后陪陪女朋友,和好兄弟出去玩一会。

就这样吧,写一写还是很好玩的。

两个星期过去了,我们的图形学入门之旅也是迎来了终章。

最后就以一个完美的作品来作为纪念我们图形学学习的短暂结束。

我们设置main函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
#include "rtweekend.h"

#include "camera.h"
#include "hittable.h"
#include "hittable_list.h"
#include "material.h"
#include "sphere.h"

int main(){
hittable_list world;

auto ground_material = make_shared<lambertian>(color(0.5,0.5,0.5));
world.add(make_shared<sphere>(point3(0,-1000,0),1000,ground_material));

for(int a = -11;a < 11;a++){
for(int b = -11;b < 11;b++){
auto choose_mat = random_double();
point3 center(a + 0.9*random_double(), 0.2, b + 0.9*random_double());

if((center - point3(4,0.2,0)).length() > 1.0){
shared_ptr<material> sphere_material;

if(choose_mat < 0.8){
//漫反射
auto albedo = color::random() * color::random();
sphere_material = make_shared<lambertian>(albedo);
world.add(make_shared<sphere>(center,0.2,sphere_material));
}else if(choose_mat < 0.95) {
//金属
auto albedo = color::random(0.5,1);
auto fuzz = random_double(0,0.5);
sphere_material = make_shared<metal>(albedo,fuzz);
world.add(make_shared<sphere>(center,0.2,sphere_material));
}else{
//玻璃
sphere_material = make_shared<dielectric>(1.5);
world.add(make_shared<sphere>(center,0.2,sphere_material));
}
}
}
}

auto material1 = make_shared<dielectric>(1.5);
world.add(make_shared<sphere>(point3(0,1,0),1.0,material1));

auto material2 = make_shared<lambertian>(color(0.4, 0.2, 0.1));
world.add(make_shared<sphere>(point3(-4, 1, 0), 1.0, material2));

auto material3 = make_shared<metal>(color(0.7, 0.6, 0.5), 0.0);
world.add(make_shared<sphere>(point3(4, 1, 0), 1.0, material3));

camera cam;

cam.aspect_ratio = 16.0 / 9.0;
cam.image_width = 800;
cam.samples_per_pixel = 100;
cam.max_depth = 50;


cam.vfov = 20;
cam.lookfrom = point3(13,2,3);
cam.lookat = point3(0,0,0);
cam.vup = vec3(0,1,0);

cam.defocus_angle = 0.6;
cam.focus_dist = 10.0;

cam.render(world);
}
image.png

两个星期的学习,都在这张图片之中了

今日收官之战,图形学之旅差不多到此为止了

可定位相机

我们的相机目前是固定视角的,比较单调。由于程序的复杂性,所以在这里我们最后实现它。首先,我们需要为我们的相机添加视场(fov)效果。这是从渲染图像一边到另外一边的视觉角度。由于我们的图像不是正方形视图,所以水平和垂直的fov是不同的。这里我们选择开发垂直fov。我们用度数来指定它,然后再构造函数中将其转换为弧度。

计算机视几何

我们继续保持从原点发出的光线,指向z轴上的平面,然后使h与这个距离的比例保持一致:

image.png

其中theta是我们的视野,即fov,这里表示h = tan(theta/2)

我们将其应用到我们的相机类中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class camera{
public:
double aspect_radio = 1.0; //图像的宽高比
int image_width = 100; //图像宽度的像素数
int samples_per_pixel = 10; //每个像素的采样次数
int max_depth = 10; //光线追踪的最大递归深度

double vfov = 90; //垂直视角(视场)
...
private:
...
void initialize(){
image_height = int(image_width/aspect_radio);
image_height = (image_height < 1) ? 1 : image_height;

pixel_samples_scale = 1.0 / samples_per_pixel;

camera_center = point3 (0,0,0);

//确认视窗的设置
auto focal_length = 1.0; //焦距设置
auto theta = degree_to_radius(vfov);
auto h = std::tan(theta/2);
auto viewport_height = 2*h*focal_length;
auto viewport_width = viewport_height*(double (image_width)/image_height);

//视图边缘的向量计算
auto viewport_u = vec3(viewport_width,0,0);
auto viewport_v = vec3(0,-viewport_height,0);
//计算视图的像素间的水平竖直增量
pixel_delta_u = viewport_u/image_width;
pixel_delta_v = viewport_v/image_height;

//计算左上角第一个像素中心的坐标
auto viewport_upper_left = camera_center - vec3(0,0,focal_length) - viewport_v/2 - viewport_u/2;
pixel00_loc = viewport_upper_left + 0.5*(pixel_delta_u+pixel_delta_v);
}
...
};

这个原理就是实现广角,图像的宽高比不变,但是视口的大小改变了,使得图像有拉伸压缩的视觉效果

我们用广角来渲染一下我们之前的图片试试,发现渲染出来的效果和之前是一样的,这是为什么呢? 因为之前设置的视口高度为2.0,而焦距为1.0,所以计算可以得到当时的视角也是90°,自然和现在是一样的了,同理我们可以通过缩小垂直视野来看到更远处的东西,这就是放大缩小的原理

摄像机的定位和定向

如果我们想要任意摆放,获得任意的视角,我们首先需要确定两个点,一个是我们放置计算机的点lookfrom,还有一个是我们想要看到点lookat

然后我们还需要定义一个方向作为摄像机的倾斜角度,即lookat-lookfrom轴的旋转。但其实还有一种方法,我们保持lookfromlookat两点不变,然后定义一个向上的方向向量,作为我们的摄像方向。

image.png

我们可以指定任何我们想要的向上向量,只要它不与视图方向平行。将这个向上向量投影到与视图方向垂直的平面上,以获得相对于摄像机的向上向量。我们将其命名为vup即向上向量。我们可以通过叉乘和向量的单位化,得到一个完整的正交归一基,然后我们就可以用(u,v,w)来描述摄像机的方向了。

这里u指的是摄像机右侧的单位向量,v指的是摄像机向上的单位向量,w指的是视图方向相反的单位向量,摄像机中心位于原点

image.png

之前我们需要让-Z和-w在同一水平面,以实现水平摄像叫角度,现在我们只需要指定我们的向上向量vup指向(0,1,0)就可以保持摄像机的水平了

我们将这些功能加入相机类中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
class camera{
public:
double aspect_radio = 1.0; //图像的宽高比
int image_width = 100; //图像宽度的像素数
int samples_per_pixel = 10; //每个像素的采样次数
int max_depth = 10; //光线追踪的最大递归深度

double vfov = 90; //垂直视角(视场)
point3 lookfrom = point3 (0,0,0); //相机位置
point3 lookat = point3 (0,0,-1); //观察点
vec3 vup = vec3(0,1,0); //相机相对向上的位置
...
private:
int image_height; //渲染图像的高度
double pixel_samples_scale; //每次采样的颜色权重
point3 camera_center; //相机的中心
point3 pixel00_loc; //像素(0,0)的位置
vec3 pixel_delta_u; //向右的偏移值
vec3 pixel_delta_v; //向下的偏移值
vec3 u,v,w; //相机的相对坐标系

void initialize(){
image_height = int(image_width/aspect_radio);
image_height = (image_height < 1) ? 1 : image_height;

pixel_samples_scale = 1.0 / samples_per_pixel;

camera_center = lookfrom;

//确认视窗的设置
auto focal_length = (lookfrom - lookat).length(); //焦距设置
auto theta = degree_to_radius(vfov);
auto h = std::tan(theta/2);
auto viewport_height = 2*h*focal_length;
auto viewport_width = viewport_height*(double (image_width)/image_height);

//计算摄像机的相对基底
w = unit_vector(lookfrom-lookat);
u = unit_vector(cross(vup,w));
v = cross(w,u);;

//视图边缘的向量计算
auto viewport_u = viewport_width * u;
auto viewport_v = viewport_height * -v;
//计算视图的像素间的水平竖直增量
pixel_delta_u = viewport_u/image_width;
pixel_delta_v = viewport_v/image_height;

//计算左上角第一个像素中心的坐标
auto viewport_upper_left = camera_center - (focal_length * w) - viewport_v/2 - viewport_u/2;
pixel00_loc = viewport_upper_left + 0.5*(pixel_delta_u+pixel_delta_v);
}
...
};

然后我们在main函数中调整我们的视角:

1
2
3
4
5
6
7
8
int main(){
...
cam.vfov = 90;
cam.lookfrom = point3 (-2,2,1);
cam.lookat = point3 (0,0,1);
cam.vup = vec3 (0,1,0);
...
}
image.png

然后我们发现渲染出来的图片不是很清晰,我们可以通过修改垂直视野来放大

1
cam.vfov = 20;
image.png

太好看了

散焦模糊

最后,我们还需要实现摄像机的一个特性:散焦模糊。在摄影中,我们把这种效果称之为景深。

在真实相机中,散焦模糊的产生是因为相机需要一个较大的孔(光圈)来收集光线,而不是一个针孔。如果只有一个针孔,那么所有物体都会清晰地成像,但进入相机的光线会非常少,导致曝光不足。所以相机会在胶片/传感器前面加一个镜头,那么会有一个特定的距离,在这个距离上看到的物体都是清晰的,然后离这个距离越远,图像就越模糊。你可以这么理解镜头:所有从焦点距离的特定点发出的光线——并且击中镜头——都会弯曲回图像传感器上的一个单一点。

在这里我们需要区分两个概念:

  • 焦距:相机中心到视口的距离,焦距决定了视场的大小。焦距越长,视场越窄。
  • 焦点距离:焦点距离是相机中心到焦点平面的距离,在焦点平面上的所有物体看起来都是清晰的

不过这里为了简化模型,我们将焦点平面和视口平面重合。

“光圈”是一个孔,用于控制镜头的有效大小。对于真正的摄像机,如果你需要更多的光线,你会使光圈变大,这将导致远离焦距的物体产生更多的模糊。在我们的虚拟摄像机中,我们可以拥有一个完美的传感器,永远不需要更多的光线,所以我们只在想要产生失焦模糊时使用光圈。

薄透镜近似

真实的相机镜头十分复杂,有传感器,镜头,光圈,胶片等…

image.png

实际上,我们不需要模拟相机内的任何一个部分,这些对于我们而言太过复杂,我们可以简化这个过程。我们将其简化成:我们从一近似平面的圆形”透镜”发出光线,并将它们发送的焦点平面的对应点上(距离透镜focal_length),在这个平面上的3D世界中的所有物体都处于完美的焦点中。

image.png

我们将这个过程展示出来:

  • 焦平面和相机视向垂直
  • 焦距是相机中心与焦点平面之间的距离
  • 视口位于焦点平面上,位于相机视角方向向量为中心
  • 像素位置的网格位于视场内
  • 从当前像素位置周围的区域随机采样(抗锯齿)
  • 相机从镜头上的随机点发射光线,通过图像样本位置

生成样本光线

没有散焦模糊时,所有的场景光线都来自相机中心(lookfrom)。为了实现散焦模糊,我们在相机中心构造一个圆盘。半径越大,散焦模糊越明显。你可以把我们的原始相机想想象成一个半径为0的散焦圆盘,所以完全不模糊。

所以我们将散焦盘的设置作为相机类的一个参数。我们将其半径作为相机系数,同时还需注意一点,相机的焦点距离也会影响散焦模糊的效果。此时,为了控制散焦模糊的程度,可以选择以下两种方式:

  • 散焦圆盘的半径:但是散焦模糊的效果会随着焦点距离的改变而被影响
  • 锥角:指定一个锥角,锥的顶点位于视口中心,底面位于相机中心,我们可以通过计算得到相应的底面半径

由于我们将从失焦盘中选择随机点,我们需要一个函数来完成这个任务random_in_unit_disk()这个和我们在random_in_unit_sphere()用到方法一样,只不过这个是二维的:

1
2
3
4
5
6
7
8
//vec3.h
inline vec3 random_in_unit_disk(){
while (true){
auto p = vec3(random_double(-1,1),random_double(-1,1),0);
if(p.length_squared() < 1)
return p;
}
}

现在我们更新相机,加入失焦模糊的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
class camera{
public:
double aspect_ratio = 1.0; //图像的宽高比
int image_width = 100; //图像宽度的像素数
int samples_per_pixel = 10; //每个像素的采样次数
int max_depth = 10; //光线追踪的最大递归深度

double vfov = 90; //垂直视角(视场)
point3 lookfrom = point3 (0,0,0); //相机位置
point3 lookat = point3 (0,0,-1); //观察点
vec3 vup = vec3(0,1,0); //相机相对向上的位置

double defocus_angle = 0; //锥角
double focus_dist = 0; //从相机中心到焦点平面中心的距离
...
private:
int image_height; //渲染图像的高度
double pixel_samples_scale; //每次采样的颜色权重
point3 camera_center; //相机的中心
point3 pixel00_loc; //像素(0,0)的位置
vec3 pixel_delta_u; //向右的偏移值
vec3 pixel_delta_v; //向下的偏移值
vec3 u,v,w; //相机的相对坐标系
vec3 defocus_disk_u; //散焦圆盘水平向量
vec3 defocus_disk_v; //散焦圆盘垂直向量

void initialize(){
image_height = int(image_width/aspect_ratio);
image_height = (image_height < 1) ? 1 : image_height;

pixel_samples_scale = 1.0 / samples_per_pixel;

camera_center = lookfrom;

//确认视窗的设置
// auto focal_length = (lookfrom - lookat).length(); //焦距设置
auto theta = degree_to_radius(vfov);
auto h = std::tan(theta/2);
auto viewport_height = 2*h*focus_dist; //确保视口和焦点平面重合
auto viewport_width = viewport_height*(double (image_width)/image_height);

//计算摄像机的相对基底
w = unit_vector(lookfrom-lookat);
u = unit_vector(cross(vup,w));
v = cross(w,u);

//视图边缘的向量计算
auto viewport_u = viewport_width * u;
auto viewport_v = viewport_height * -v;
//计算视图的像素间的水平竖直增量
pixel_delta_u = viewport_u/image_width;
pixel_delta_v = viewport_v/image_height;

//计算左上角第一个像素中心的坐标
auto viewport_upper_left = camera_center - (focus_dist * w) - viewport_v/2 - viewport_u/2;
pixel00_loc = viewport_upper_left + 0.5*(pixel_delta_u+pixel_delta_v);

//计算相机散焦圆盘的基向量
auto defocus_radius = focus_dist * std::tan(degree_to_radius(defocus_angle/2));
defocus_disk_u = u*defocus_radius;
defocus_disk_v = v*defocus_radius;
}

ray get_ray(int i,int j){
//构造一个从散焦圆盘开始的随机采样射线,指向(i,j)像素周围的采样点

auto offset = sample_square();
auto pixel_sample = pixel00_loc + ((i+offset.x())*pixel_delta_u) + ((j+offset.y())*pixel_delta_v);

auto ray_origin = (defocus_angle <= 0) ? camera_center :defocus_disk_sample();
auto ray_direction = pixel_sample - ray_origin;

return ray(ray_origin,ray_direction);
}
...
point3 defocus_disk_sample() const {
// 返回散焦盘的上的随机点
auto p = random_in_unit_disk();
return camera_center + (p[0]*defocus_disk_u) + (p[1]*defocus_disk_v);
}
...
};

现在我们的相机具备了景深的效果,然我们来渲染试试效果吧:

image.png

Good,到此为止,我们的相机终于完成了!

继续冲

介电质材料

乍一听可能感觉很厉害,但实际上水,玻璃,钻石一类的材料都属于介电质,他们通常有以下特点:

  • 不导电:但会光会与该材料发生相互作用,主要表现有 反射与折射
  • 折射率:是材料特有的属性,决定光线进入材料时的弯曲程度,不同的材料有不同的折射率

当光线击中他们时,会分成反射光线和折射(透射)光线。我们将通过随机选择的反射换和折射来处理这个情况,并确保每次交互只生成一个光线。

当光线从一种材料的周围环境进入该材料本身,如(玻璃和水)时,会发生折射,光线会弯曲。折射光线的弯曲程度由材料的折射率决定。通常,折射率n是一个描述光线从真空进入材料时弯曲程度的单一值。当一种透明材料嵌入另一种透明材料中时,可以用相对折射率来描述折射:物体的折射率/环境的折射率。如渲染一个水下的玻璃,那么其有效折射率为玻璃的折射率/水的折射率

斯涅尔定律

折射一般由斯涅尔定律来描述:

image.png

这里的符号我不好用Latex打出来,所以折射率用eta来描述,并且这里结合一张图来描述:

img

为了确定折射光线的方向,我们需要解出sin(theta’):

1
sin(theta0) = eta/eta0 * sin(theta)

我们可以将折射光线分解成垂直法线方向和平行法线方向:

1
2
3
R = R_parallel + R_vertical
后续简写成:
R = R_p + R_v

根据斯涅尔定理,折射光线的垂直分量与入射光线的垂直分量也成比例:

1
R_v0 = eta/eta0 * R_v

所以可以计算出

1
2
3
4
R_p = (R*n)*n 	//这里的n已经单位化了,所以不用除|n|,直接*n即可
R_v = R - R_p = R - (R*n)*n
R*n = -cos(theta0)
R_v0 = eta/eta0 * (R + cos(theta)*n)

由于单位向量下,|R|^2 = |R_p|^2 + |R_v|^2,我们有:

1
2
3
4
R_p0 = -sqrt(1 - |R_v0|)*n
//最终合并得到R0
R_v0 = eta/eta0 * (R + (-R*n)*n) //这里替换掉cos方便计算
R0 = R_p0 + R_v0

我们可以写出向量的折射函数:

1
2
3
4
5
6
7
//vec3.h
inline vec3 refract(const vec3& uv,const vec3& n,double etai_over_etat){
auto cos_theta = std::fmin(dot(-uv,n),1.0);
vec3 r_out_perp = etai_over_etat * (uv + cos_theta*n);
vec3 r_out_parallel = -std::sqrt(std::fabs(1.0-r_out_perp.length_squared()))*n;
return r_out_perp + r_out_parallel;
}

在此基础上,我们可以创建出我们的介电质材料类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class dielectric : public material{
public:
dielectric(double refraction_index) : refraction_index(refraction_index) {}

bool scatter(const ray& r_in,hit_record& rec,color& attenuation, ray& scattered) const override {
attenuation = color(1.0,1.0,1.0);
double ri = rec.front_face ? (1.0/refraction_index) : refraction_index;

vec3 unit_direction = unit_vector(r_in.direction());
vec3 refracted = refract(unit_direction,refracted,ri);

scattered = ray(rec.p,refracted);
return true;
}
private:
double refraction_index; //在真空中的折射率,或者材料的折射比例
};

然后我们更改main函数重新渲染看看效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main(){
...
auto material_ground = make_shared<lambertian>(color(0.8, 0.8, 0.0));
auto material_center = make_shared<lambertian>(color(0.1, 0.2, 0.5));
auto material_left = make_shared<metal>(color(0.8, 0.8, 0.8),0.3);
auto material_right = make_shared<metal>(color(0.8, 0.6, 0.2),0.0);
auto material_front = make_shared<dielectric>(1.50);

world.add(make_shared<sphere>(point3( 0.0, -100.5, -1.0), 100.0, material_ground));
world.add(make_shared<sphere>(point3( 0.0, 0.0, -1.5), 0.5, material_center));
world.add(make_shared<sphere>(point3(-1.0, 0.0, -1.0), 0.5, material_left));
world.add(make_shared<sphere>(point3( 1.0, -0.3, -1.0), 0.2, material_right));
world.add(make_shared<sphere>(point3(0.0,-0.2,-1.0),0.3,material_front));
...
}
image.png

可以看到中间一个怪怪的就是我们的玻璃球体,现在它只有折射属性,所以看起来怪怪的,中间的小黑点则是因为其光线追踪到的未被遮挡的阴影。

全反射

如果介电材料只是折射到话,也会遇到一些问题,对于一些光线角度,它的计算并不符合斯涅尔定理。如果光线以较大的入射角进入交界处,可能会以大于90°的角度折射出来,这显然是不可能的,所以这里我们将要用到我们高中所学的知识——全反射,来解决这个问题。

至于判断什么时候计算,我们计算一下折射角度的sin值,如果sin值大于1,就说明发生全反射。我们就不用折射函数来计算,用反射函数来计算出射光线。我们可以写出如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
bool scatter(const ray& r_in,hit_record& rec,color& attenuation, ray& scattered) const override {
attenuation = color(1.0,1.0,1.0);
double ri = rec.front_face ? (1.0/refraction_index) : refraction_index;

vec3 unit_direction = unit_vector(r_in.direction());
double cos_theta = std::fmin(dot(-unit_direction,rec.normal),1.0);
double sin_theta = std::sqrt(1.0 - cos_theta*cos_theta);

bool cannot_refract = ri * sin_theta > 1.0;
vec3 direction;

if(cannot_refract)
direction = reflect(unit_direction,rec.normal);
else
direction = refract(unit_direction,rec.normal,ri);

scattered = ray(rec.p,direction);
return true;
}

我们现在采用这个新的dielectric::scatter()函数渲染先前的场景,会发现没有任何变化。这是因为给定一个折射率大于空气的材料的球体,不存在入射角会导致全反射。这是由于球体的几何形状,入射和出射的角度经过两次折射还是一样的。

所以我们这里模拟空气的折射率和水一样,然后把球体的材料改为空气,所以我们设置它的折射系数为index of refraction of air / index of refraction of water,然后我们渲染试试:

image.png

Schlick近似

实际生活中,光线击中透明材质表面时,一部分光会反射,还有一部分光会折射,这个比例取决于入射角和两种介质的折射率。这个现象叫做菲尼尔效应,其严格的计算十分复杂,但好在我们有一个名为Schlick近似的计算方式,它是简化的替代方案,而且十分简便。我们可以看下它的内容:

image.png

我们在此基础之上可以改进我们的dielectric类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class dielectric : public material{
public:
dielectric(double refraction_index) : refraction_index(refraction_index) {}

bool scatter(const ray& r_in,hit_record& rec,color& attenuation, ray& scattered) const override {
attenuation = color(1.0,1.0,1.0);
double ri = rec.front_face ? (1.0/refraction_index) : refraction_index;

vec3 unit_direction = unit_vector(r_in.direction());
double cos_theta = std::fmin(dot(-unit_direction,rec.normal),1.0);
double sin_theta = std::sqrt(1.0 - cos_theta*cos_theta);

bool cannot_refract = ri * sin_theta > 1.0;
vec3 direction;

//如果发生折射,还要根据Schlick近似,判断是否反射,如果反射率大于随机值则反射
if(cannot_refract || reflectance(cos_theta,ri) > random_double())
direction = reflect(unit_direction,rec.normal);
else
direction = refract(unit_direction,rec.normal,ri);

scattered = ray(rec.p,direction);
return true;
}
private:
double refraction_index; //在真空中的折射率,或者材料的折射比例

static double reflectance(double cosine,double refraction_index){
//使用Schlick近似计算反射率
auto r0 = (1 - refraction_index) / (1 + refraction_index);
r0 = r0*r0;
return r0 + (1-r0)*std::pow((1 - cosine),5);
}
};

对比一下:

image.png

左边确实更加逼真了。

建模一个空心玻璃球

我们建模一个空心玻璃球。这是一个由厚度的球体,里面有一个空气球。光线穿过这个物体,先击中外球,然后折射,然后穿过球内的空气。然后又击中球的内表面,折射,再击中外球的内表面,最后返回场景大气中。

我们设置外球,使用标准玻璃建模,折射率为1.50/1.00(从空气射入玻璃)。内球不同,内球使用空气建模,折射率设置为1.00/1.50(从玻璃射入空气)

我们设置一下main函数,再次渲染:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main(){
...
auto material_ground = make_shared<lambertian>(color(0.8, 0.8, 0.0));
auto material_center = make_shared<lambertian>(color(0.1, 0.2, 0.5));
auto material_left = make_shared<dielectric>(1.50);
auto material_right = make_shared<metal>(color(0.8, 0.6, 0.2),0.0);
auto material_bubble = make_shared<dielectric>(1.00/1.50);

world.add(make_shared<sphere>(point3( 0.0, -100.5, -1.0), 100.0, material_ground));
world.add(make_shared<sphere>(point3( 0.0, 0.0, -1.0), 0.5, material_center));
world.add(make_shared<sphere>(point3(-1.0, 0.0, -1.0), 0.5, material_left));
world.add(make_shared<sphere>(point3( 1.0, 0.0, -1.0), 0.5, material_right));
world.add(make_shared<sphere>(point3(-1.0, 0.0, -1.0), 0.4, material_bubble));
...
}
image.png

感觉不错啊,今天就到此为止吧

继续图形学的学习,我打算在下一周左右结束图形学的学习,因为要期中考试了(晕),现在赶赶进度

金属

一个材质的抽象类

如果想让不同的物体拥有不同的材质,我们可以设置一个通用的材质类,具有许多参数。或者我们可以有一个抽象的材质类,封装特定材质的独特行为,这里我们使用第二种方式,因为这样便于我们更好的组织代码,设置这么一个类,对于不同的材质,我们需要做两件事:

  • 产生一个散射光线(或者吸收入射光线)
  • 如果散射了,光线应该怎么衰减

在此基础之上,我们可以定义出我们的抽象类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#ifndef RENDER_C___MATERIAL_H
#define RENDER_C___MATERIAL_H

#include "hittable.h"

class material{
public:
virtual ~material() = default;

virtual bool scatter(const ray& r_in,hit_record& rec,color& attenuation, ray& scattered) const{
return false;
}
};

#endif //RENDER_C___MATERIAL_H

描述光线-物体交点的数据结构

hit_record的设置目的是为了避免一大堆的参数,所以我们设置一个封装的类型,将信息参数放入其中。当然,由于hittable.hmaterials.h需要在代码中能够引用对方的类,为了避免他们的循环依赖,我们向hittable文件头中添加class material来指定类的指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class material;

class hit_record{
public:
point3 p;
vec3 normal;
shared_ptr<material> mat; //附带了击中材料的材质的信息
double t;
bool front_face;

void set_face_normal(const ray& r,const vec3& outward_normal){
//设置交点的法线方向
front_face = dot(r.direction(),outward_normal) < 0;
normal = front_face ? outward_normal : -outward_normal;
}
};

hit_record将一堆参数放入类中,然后作为一个组合发送。当光线击中一个表面是,hit_record中的材料指针被设置为球体在main中给定的指针材料。且当ray_color()获取hit_record时,它可以调用材料指针的成员函数来找出散射(如果有的话)

现在我们需要设置球体sphere类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class sphere: public hittable{
public:
sphere(const point3& center, double radius) : center(center), radius(std::fmax(0,radius)) {

}

bool hit(const ray& r,interval ray_t,hit_record& rec) const override{
vec3 oc = center - r.origin();
auto a = r.direction().length_squared();
auto h = dot(r.direction(),oc);
auto c = oc.length_squared() - radius*radius;

auto discriminant = h*h - a*c;
if(discriminant < 0.0){
return false;
}

//解t并进行判断
auto sqrtd = std::sqrt(discriminant);
auto root = (h - sqrtd) / a;
if(!ray_t.surrounds(root)){
root = (h + sqrtd) / a;
if(!ray_t.surrounds(root))
return false;
}

rec.t = root;
rec.p = r.at(rec.t);
vec3 outward_normal = (rec.p - center) / radius;
rec.set_face_normal(r,outward_normal);
rec.mat = mat;

return true;
}

private:
point3 center;
double radius;
shared_ptr<material> mat;
};

光线散射与反射率的建模

反射率是我们接下来需要关注的一件事情,反射率和材质 颜色有关,也会随入射光线方向变化

而我们先前的Lambertian 反射,它的反射有三种情况:

  • 根据反射率R,总是散射光线并衰减光线。
  • 光线有(1-R)的概率散射后不衰减
  • 综上两个情况

这里为了程序的简易性,我们选择第一种情况,来实现我们的Lambertian 材料:

1
2
3
4
5
6
7
8
9
10
11
12
13
class lambertian: public material{
public:
lambertian(const color& albedo) : albedo(albedo) {}

bool scatter(const ray& r_in,hit_record& rec,color& attenuation, ray& scattered) const override{
auto scatter_direction = rec.normal + random_unit_vector();
scattered = ray(rec.p,scatter_direction);
attenuation = albedo;
return true;
}
private:
color albedo;
};

如果你仔细阅读会发现,我们使用的是random_unit_vector来生成一个随机的向量,这个向量可能和法线是等大反向的,从而导致零散射方向向量的情况,导致后许发生各种不良的情况。所以我们需要拦截这种可能性

所以我们创建一个新的向量方法——该方法返回一个布尔值,判断各个方向的维度会不会趋近0:

1
2
3
4
5
bool near_zero() const {
//判断各个分量是不是趋近于0
auto s = 1e-8;
return (std::fabs(e[0]) < s) && (std::fabs(e[1]) < s) && (std::fabs(e[2]) < s);
}

然后更新我们的Lambertian反射:

1
2
3
4
5
6
7
8
9
10
11
bool scatter(const ray& r_in,hit_record& rec,color& attenuation, ray& scattered) const override{
auto scatter_direction = rec.normal + random_unit_vector();

//拦截危险向量
if(scatter_direction.near_zero())
scatter_direction = rec.normal;

scattered = ray(rec.p,scatter_direction);
attenuation = albedo;
return true;
}

镜像反射

对于抛光的金属,光线不会随机的散射,而是对称的反射:

image.png

这个过程我们可以用向量来实现计算:

  • 首先我们计算出向量v在n上的投影长度,然后取相反数得到b
  • 然后通过v+2b,来计算出反射后的光线

我们可以写出一下程序来计算反射函数:

1
2
3
4
5
//vec3.h
inline vec3 reflect(const vec3& v, const vec3& n){
//点积是v在n上的投影长度,即b的长度,*-2n则是为了校准方向
return v - 2* dot(v,n)*n;
}

然后我们用这个函数实现我们的金属材质:

1
2
3
4
5
6
7
8
9
10
11
12
13
class metal: public material{
public:
metal(const color& albedo) : albedo(albedo){}

bool scatter(const ray& r_in,hit_record& rec,color& attenuation, ray& scattered) const override{
vec3 reflected = reflect(r_in.direction(),rec.normal);
scattered = ray(rec.p,reflected);
attenuation = albedo;
return true;
}
private:
color albedo;
};

我们接下来修改ray_color()函数以应用更改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
color ray_color(const ray& r,int depth, const hittable& world) const {
//如果不进行光线的追踪,就不会光线返回
if(depth <= 0)
return {0,0,0};

hit_record rec;

if (world.hit(r, interval(0.001, infinity), rec)) {
ray scattered;
color attenuation;
if(rec.mat->scatter(r,rec,attenuation,scattered))
return attenuation* ray_color(scattered,depth-1,world);
return {0,0,0};
}

vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0, 1.0, 1.0) + a*color(0.5, 0.7, 1.0);
}

然后接着更行sphere的构造函数以初始化材质指针mat:

1
2
3
4
5
class sphere : public hittable {
public: sphere(const point3& center, double radius, shared_ptr<material> mat)
: center(center), radius(std::fmax(0,radius)), mat(mat) {}
...
};

金属球体在场景中

现在我们向场景中添加我们的金属球体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main(){
hittable_list world;

auto material_ground = make_shared<lambertian>(color(0.8, 0.8, 0.0));
auto material_center = make_shared<lambertian>(color(0.1, 0.2, 0.5));
auto material_left = make_shared<metal>(color(0.8, 0.8, 0.8));
auto material_right = make_shared<metal>(color(0.8, 0.6, 0.2));

world.add(make_shared<sphere>(point3( 0.0, -100.5, -1.0), 100.0, material_ground));
world.add(make_shared<sphere>(point3( 0.0, 0.0, -1.5), 0.5, material_center));
world.add(make_shared<sphere>(point3(-1.0, 0.0, -1.0), 0.5, material_left));
world.add(make_shared<sphere>(point3( 1.0, -0.3, -1.0), 0.2, material_right));
...
cam.render(world);
}

成功渲染了出来,看:

image.png

模糊反射

我们的金属球体太过完美,但是现实中我们往往也会看到各种磨砂材质的金属,所以在这里我们要引入一个新的变量fuzz,来随机化反射方向。我们使用一个随机点,以原始起点为中心,来模糊反射的光线。

image.png

我们用fuzz来决定模糊球体的大小,模糊球体越大,反射效果就越明显。当模糊球体较大或光线几乎平行于表面时(称为掠射光线),计算出的反射光线可能会指向物体内部,即在物体表面下方。对于这种情况,可以选择简单地吸收这些光线,即认为它们没有从物体表面反射出去。

为了确保模糊球体相对于反射光线的尺度是一致的,需要对反射光线进行归一化处理,即调整其长度为1。这样做可以确保无论反射光线的长度如何变化,模糊球体的尺度都是相对于光线方向的,而不是其长度。

于是我们可以更改以下程序以实现这个功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class metal: public material{
public:
metal(const color& albedo) : albedo(albedo){}

bool scatter(const ray& r_in,hit_record& rec,color& attenuation, ray& scattered) const override{
vec3 reflected = reflect(r_in.direction(),rec.normal);
reflected = unit_vector(reflected) + (fuzz*random_unit_vector());
scattered = ray(rec.p,reflected);
attenuation = albedo;
return (dot(scattered.direction(),rec.normal) > 0);
}
private:
color albedo;
double fuzz;
};

然后调整一下我们的main函数:

1
2
3
4
5
6
7
8
int main(){
...
world.add(make_shared<sphere>(point3( 0.0, -100.5, -1.0), 100.0, material_ground));
world.add(make_shared<sphere>(point3( 0.0, 0.0, -1.5), 0.5, material_center));
world.add(make_shared<sphere>(point3(-1.0, 0.0, -1.0), 0.5, material_left));
world.add(make_shared<sphere>(point3( 1.0, -0.3, -1.0), 0.2, material_right));
...
}

渲染出来的是这样的:

image.png

可以看到左边有明显的模糊感

中途歇了一段时间写物理作业,然后昨天学了下垃圾回收。现在继续开始我们的图形学学习。

漫反射材质

现在我们有了物体和每个像素的多个光线,现在我们可以尝试制作更加逼真的材质了。我们先从我们的漫反射材质开始(也称为哑光材质)开始。不过这里我们将几何体和材质分开使用,这使得我们可以将一种材质应用于多种物体,或者将多种材质应用于一种物体。这样分开使用的方法更加灵活也更加容易拓展,所以我们选择这种方式来实现我们的材质。

一个简单的漫反射材质

一个漫反射的物体不会发出自己的光,它会吸收周围的环境的颜色,然后通过自己固有的颜色来调节。从扩散表面反射的光线方向是随机的,我们向两个漫反射材质之间发射三束光线,他们的行为会有所不同

image.png

当然,他们有可能会被吸收,也有可能会被反射。表面越暗,说明光线被吸收的可能性更大(黑色说明光线被完全吸收了)。实际上我们我们可以用随机化方向的算法产生看起来哑光的材质,最简单的实现就是:一个光线击中表面,有相等的机会向任何方向弹射出去

image.png

这样的漫反射材质是最简单的,我们需要实现一些随机反射光线的方法,我们向vec类中再额外实现几个函数,以完成能够生成任意随机的向量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class vec3 {
public:
...

double length_squared() const {
return e[0]*e[0] + e[1]*e[1] + e[2]*e[2];
}

static vec3 random() {
return vec3(random_double(), random_double(), random_double());
}

static vec3 random(double min, double max) {
return vec3(random_double(min,max), random_double(min,max), random_double(min,max));
}
};

现在我们需要操作这个随机向量,以确保我们的向量生成在外半球。我们用最简单的方法来实现这个过程,即随机重复生成向量,直到生成了满足我们需求的标准样本,然后采用它,实现它的具体方法如下:

  • 在该点的单位球体内生成一个随机向量
  • 将这个向量单位化,以确保指向球面
  • 如果这个向量单位化后,不在我们想要的半球,将其反转

我们开始这个算法的具体实现,首先在包围单位球体的立方体内随机生成一个点(即x,y,z都在[-1,+1]内)。如果这个点在单位球体之外,则重新生成一个点,直到找到一个在单位球体内的一个点:

image.png

然后我们将其单位化

image.png

我们先实现这个功能吧:

1
2
3
4
5
6
7
8
9
10
//vec3.h
inline vec3 random_unit_vector(){
while(true){
auto p = vec3::random(-1,1);
auto lensq = p.length_squared();
if(lensq <= 1){
return p/ sqrt(lensq);
}
}
}

实际上这里还会有一点小问题,我们需要直到。由于浮点数的精度是有限的,一个很小的数在平方后可能会向下溢出到0。也就是说,如果三个坐标都足够小(非常接近球心),向量在单位化操作下可能会变成[+-无穷,+-无穷,+-无穷]。为了解决这个问题我们需要设置一个下限值,由于我们使用的是double,所以在这里我们可以支持大于1e-160的值,所以我们更新一下程序:

1
2
3
4
5
6
7
8
9
10
11
//vec3.h
inline vec3 random_unit_vector(){
while(true){
auto p = vec3::random(-1,1);
auto lensq = p.length_squared();
//1e-160太极限了,所以放宽一点
if(lensq > 1e-100 && lensq <= 1){
return p/ sqrt(lensq);
}
}
}

现在我们计算得到了单位球面上的随机向量,需要将其与表面法线比较,以判断其是否为位于正确的半球

image.png
1
2
3
4
5
6
7
8
//vec3.h
inline vec3 random_on_hemisphere(const vec3& normal){
vec3 on_unit_sphere = random_unit_vector();
if (dot(on_unit_sphere,normal) > 0.0)
return on_unit_sphere;
else
return -on_unit_sphere;
}

接下来我们需要将其应用到上色函数中,这里的话我们还需要注意一点,就是光线颜色的反射率,如果反射率为100%,那么我们看到的都是白色的,如果光线反射率是0%,那么光线都被吸收,我们看到的物体是黑色的。这里我们设置我们的光线反射率为50%,并将其应用到我们的ray_color()中:

1
2
3
4
5
6
7
8
9
10
11
12
color ray_color(const ray& r, const hittable& world) const {
hit_record rec;

if (world.hit(r, interval(0, infinity), rec)) {
vec3 direction = random_on_hemisphere(rec.normal);
return 0.5 * ray_color(ray(rec.p, direction), world);
}

vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0, 1.0, 1.0) + a*color(0.5, 0.7, 1.0);
}

然后我们可以看到渲染出来的图片:

image.png

限制光线的反射次数(递归深度)

注意,我们的ray_color函数是递归的,我们却不能确保它何时停止递归,也就是它不再击中任何东西的时候。当情况比较复杂的时候可能会花费很多时间或者是栈空间,为了防止这种情况我们需要限制这个程序的最大递归深度,我们将其作为camera类的一个属性,并且更改我们的ray_color()函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class camera{
public:
double aspect_radio = 1.0; //图像的宽高比
int image_width = 100; //图像宽度的像素数
int samples_per_pixel = 10; //每个像素的采样次数
int max_depth = 10; //光线追踪的最大递归深度

void render(const hittable& world){
initialize();

std::cout << "P3\n" << image_width << " " << image_height << "\n255\n";
for(int j=0;j<image_height;j++){
std::clog << "\rScanlines remaining: " << (image_height - j) << ' ' << std::flush;
for(int i=0;i<image_width;i++){
color pixel_color(0,0,0);
for(int sample = 0;sample < samples_per_pixel; sample++){
ray r = get_ray(i,j);
pixel_color += ray_color(r,max_depth,world);
}
write_color(std::cout,pixel_color*pixel_samples_scale);
}
}
std::clog << "\rDone. \n";
}
...
private:
...

color ray_color(const ray& r,int depth, const hittable& world) const {
//如果不进行光线的追踪,就不会光线返回
if(depth <= 0)
return {0,0,0};

hit_record rec;

if (world.hit(r, interval(0, infinity), rec)) {
vec3 direction = random_on_hemisphere(rec.normal);
return 0.5 * ray_color(ray(rec.p, direction), depth - 1,world);
}

vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0, 1.0, 1.0) + a*color(0.5, 0.7, 1.0);
}
};

同时我们可以通过更新main函数来更改我们的深度限制:

1
2
3
4
5
6
7
8
9
int main(){
...
cam.aspect_radio = 16.0/9.0;
cam.image_width = 800;
cam.samples_per_pixel = 100;
cam.max_depth = 50;

cam.render(world);
}

渲染出来的效果差不多

image.png

小阴影块

我们这里还需要解决一个问题,当我们计算光线与表面的交点时,计算机会尝试准确的计算出它的交点,但是由于浮点数的误差,我们很难准确的计算出来,导致交点会略微偏移,这其中就有一部分的交点会在表面之下,会导致从表面随机散射的光线再次与表面相交,可能会解出t = 0.000001,也就是在很短的距离内,再次击中表面。

所以为了解决这个问题,我们需要设置一个阈值,当t小于一定程度的时候,我们不将视作有效的命中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
color ray_color(const ray& r,int depth, const hittable& world) const {
//如果不进行光线的追踪,就不会光线返回
if(depth <= 0)
return {0,0,0};

hit_record rec;

if (world.hit(r, interval(0.001, infinity), rec)) {
vec3 direction = random_on_hemisphere(rec.normal);
return 0.5 * ray_color(ray(rec.p, direction), depth - 1,world);
}

vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0, 1.0, 1.0) + a*color(0.5, 0.7, 1.0);
}

我们在此基础上再尝试渲染一次,看看效果:

image.png

右边是修复后的效果,我们可以明显感受到其变得更加明亮,更加真实

Lambertian反射

我们先前用的是随机的光线来实现漫反射,这样的话会虽然可以正确的渲染出我们的图像,但是缺少了一点真实感,在实际的漫反射中,反射光线遵循一定的规律(应该是在图形学中通常使用Lambertian定理来实现漫反射)

Lambertian反射的核心思想是反射光的亮度和观察的方向无关,而是和入射光线和表面法线的夹角有关,实际上他们之间存在I = I0*cos(/theta)的关系。

为了在光线追踪中模拟Lambertian分布,我们可以通过移动单位球的方式以实现这个过程,我们向随机生成的单位向量添加一个法向量,来实现这个移动。这么说可能比较抽象,可以看下以下图片:

image.png

我们将单位球沿着法线方向移动生成一个新的单位球,此时我们随机分布的点位是大致符合Lambertian反射的,具体的内容可以自己去尝试Lambertian分布的搜索。

这个过程看起来很复杂,实际上实现起来十分简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
color ray_color(const ray& r,int depth, const hittable& world) const {
//如果不进行光线的追踪,就不会光线返回
if(depth <= 0)
return {0,0,0};

hit_record rec;

if (world.hit(r, interval(0.001, infinity), rec)) {
//我们只需要在这里添加一个法向量即可
vec3 direction = rec.normal + random_on_hemisphere(rec.normal);
return 0.5 * ray_color(ray(rec.p, direction), depth - 1,world);
}

vec3 unit_direction = unit_vector(r.direction());
auto a = 0.5*(unit_direction.y() + 1.0);
return (1.0-a)*color(1.0, 1.0, 1.0) + a*color(0.5, 0.7, 1.0);
}

然后我们再次尝试渲染:

image.png

右图是经过Lambertian反射后生成的渲染图像,可以注意到经过Lambertian反射的设置之后,右图的阴影变得更加明显。这是因为散射的光线更多的指向法线,使其更加击中,对于交界处的光线,更多的直接向上反射了,所以在球体下方的颜色会更暗淡。

伽马矫正以实现准确的色彩强度

大多数显示设备(如显示器、电视和投影仪)的亮度输出与输入信号的关系是非线性的。这种非线性关系称为伽马(gamma),通常在2.2左右。这意味着,如果直接将线性空间(即未进行伽马校正的空间)的像素值发送到显示设备,显示出来的图像会显得比预期的要暗。

人眼对亮度的感知也是非线性的。我们的眼睛对暗部细节比亮部细节更敏感。通过伽马校正,可以使图像的亮度分布更符合人眼的感知特性,从而在视觉上获得更好的效果。

这里我们需要使用一个简单的gamma矫正模型,

image.png

我们编写我们的write_color()函数以实现从线性空间到伽马空间的变换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
inline double linear_to_gamma(double linear_component){
if(linear_component >0)
return std::sqrt(linear_component);
return 0;
}

void write_color(std::ostream& out,const color& pixel_color){
auto r = pixel_color.x();
auto g = pixel_color.y();
auto b = pixel_color.z();

//从线性空间到伽马空间的转换
r = linear_to_gamma(r);
g = linear_to_gamma(g);
b = linear_to_gamma(b);

//使用区间RGB[0,1]计算RGB值
static const interval intensity(0.000,0.999);
int rbyte = int (256*intensity.clamp(r));
int gbyte = int (256*intensity.clamp(g));
int bbyte = int (256*intensity.clamp(b));

out << rbyte << ' ' << gbyte << ' ' << bbyte << '\n';
}

现在我们对我们的write_color()进行了伽马矫正,现在我们再来看看效果:

image.png

左边的是经过伽马矫正之后的图片,确实更好看了一点,哈哈。

大佬的技术博客,拜读了一下,理解的七七八八,做一个记录和研究吧。

文章地址:Baby’s First Garbage Collector – journal.stuffwithstuff.com

简易的垃圾回收机制

在计算机运行的过程中,会不断的分配和释放内存。尤其是在一些低级的语言中,内存的分配和释放都需要程序员手动分配。如果不对内存加以分配和管理,就会导致系统资源耗尽,从而发生内存泄露。或者如果错误的释放了正在使用的内存空间,那么又会导致系统崩溃。所以一个合理的垃圾回收机制,可以自动识别并回收不再使用的内存,减轻程序员的负担。

垃圾回收的几种实现

  • 引用计数:为每个对象维护一个引用计数器,初始化为1,每当有一个新的引用指向它,计数器就+1。每当一个引用失效,则将计数器-1。当计数器为0时,说明对象不再被引用,就被垃圾回收器回收了。
  • 标记-清除算法:从一个”根对象”开始,通过引用关系遍历所有可以到达的对象,并将其标记为“存活”。然后回收器再扫描一遍内存空间,找出没有被标记的”垃圾”,并将其回收
  • 复制算法:将内存分为两块,分别是“从空间”和“到空间”,再内存分配时,只使用从空间,当从空间被填满时,回收器开始工作,其从根对象开始遍历所有的可达对象,并将其复制到到空间。然后将从空间和到空间的身份进行互换,继续使用从空间进行内存的分配
  • 标记-整理算法:首先从根对象开始遍历所有可达对象,然后移动存活对象到内存空间的另一端,并更新他们的引用。最后回收器,回收所有没有使用的内存碎片。相当于复制算法和标记-清除算法的结合版

这里我们将要使用的垃圾回收机制是 标记-清除算法 ,现在我们需要明确什么是垃圾什么是被使用中的内存空间。

垃圾,指的是之前分配但现在不再使用的内存。使用中的定义则较为复杂,有以下几点:

  • 任何处于作用域中的变量所引用的对象都是处于使用中的
  • 任何被另一个正在使用中的对象所引用的对象都是处于使用中的(注意理解,这是一条递归规则)

所以我们需要从变量开始遍历对象,以达到所有可以到达的对象,对于不可到达的对象,将其收回。

标记与清除

关键在于对对象的遍历和标记,其原理十分的简单:

  • 从根部开始遍历整个对象图。每到达一个对象,就将其上的”标记”设置为true
  • 完成后,遍历查找所有的未被设置的对象,并将其删除

对象

垃圾回收器常常被用于各种编程语言中,但是我们这只是演示其功能,我们就只用基本的数据类型,和简单的虚拟机来实现这个功能并创建一些可回收的垃圾。我们用一个枚举变量来标识对象的类型:

1
2
3
4
typedef enum{
OBJ_INT,
OBJ_PAIR
} ObjectType;

这里定义了两种数据类型,一个是数据类型,一个是成对类型。这个对可以是各种组合,可以是一对数,或者一对对对象,或者一个数和一个对对象。这样的话可以实现对象之间的相互引用,这样的话就可以得到一个连续的引用对象的树,我们我们可以定义出它:

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct sObject{
ObjectType type;
union{
// OBJ_INT
int value;

//OBJ_PAIR
struct {
struct sObject * head;
struct sObject * tail;
};
};
} Object;

type字段来标识值的类型——int/pair,然后用联合体来存储

小小的虚拟机

现在我们可以在虚拟机种使用这个数据类型,我们的虚拟机拥有一个栈,用来存储当前作用域中的变量。

1
2
3
4
typedef struct {
Object * stack[STACK_MAX];
int stackSize;
} VM;

这个结构体中包含两个部分:

  • 一个是一个栈空间,用来存储栈中存储的对象的地址
  • 一个是当前栈的大小

我们在此基础之上继续编写一个创建并初始化虚拟机的函数:

1
2
3
4
5
VM * newVM(){
VM * vm = malloc(sizeof (VM));
vm->stackSize = 0;
return vm;
}

然后我们添加能对虚拟机的栈进行操作的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void push(VM * vm,Object * value){
if(vm->stackSize > STACK_MAX){
fprintf(stderr,"Stack overflow");
exit(0);
}
vm->stack[vm->stackSize++] = value;
}
Object * pop(VM * vm){
if(vm->stackSize < 0){
fprintf(stderr,"Stack underflow");
exit(0);
}
return vm->stack[--vm->stackSize];
}

我们现在可以存放变量了,那么我们也需要一个创建变量的程序:

1
2
3
4
5
Object * newObject(VM * vm,ObjectType type){
Object * object = malloc(sizeof (Object));
object->type = type;
return object;
}

上面的辅助函数实现了对变量内存的分配,和内存类型的设定,我们在此基础上编写指定的数据类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void pushInt(VM *vm,int intValue){
Object* object = newObject(vm,OBJ_INT);
object->value = intValue;
push(vm,object);
}
Object * pushPair(VM * vm){
Object* object = newObject(vm,OBJ_PAIR);
/*
原文这里用的是pop,但我发现使用pop会导致栈上数量减少,从而导致在标识环节无法为所有变量打上标记
所以改成相对索引,才能达到我们想要的效果
*/
object->tail = vm->stack[vm->stackSize-2];
object->head = vm->stack[vm->stackSize-1];

push(vm,object);
return object;
}

这就是我们的小虚拟机,如果我们真的有一个调用这些函数的解析器和解释器,那么我们手上就真的有一门语言了。如果我们有足够的内存,它甚至可以运行真正的程序。

标记

我们可以在之前的基础上开始我们的垃圾回收,第一个阶段就是标记。我们需要遍历所有可以到达的对象并设置他们的标记位,现在我们需要为之前的Object结构设置一个标记位。且当我们创建一个新的对象时,我们修改newObject()以初始化marked为零。

1
2
3
4
5
6
7
8
9
10
typedef struct sObject{
unsigned char marked;
...
} Object;
Object * newObject(VM * vm,ObjectType type){
Object * object = malloc(sizeof (Object));
object->type = type;
object->marked = 0;
return object;
}

接下来我们开始准备标记所有可到达的函数,我们先从内存中的变量开始(即遍历栈),不过首先我们需要实现我们的标记函数,然后再遍历标记中调用:

1
2
3
4
5
6
7
void mark(Object * object){
object->marked = 1;
}
void markAll(VM * vm){
for(int i=0;i<vm->stackSize;i++)
mark(vm->stack[i]);
}

不过这还远远不够,我们标记的对象本身确实是可以到达的,但是我们还有一种成对类型。在这里,我们需要意识到可达性是传递的,我们可以用递归实现这个过程:

1
2
3
4
5
6
7
8
9
void mark(Object * object){
//这里是用于检测当前对象是否被遍历过,避免两个对象互相引用的情况
if(object->marked) return;
object->marked = 1;
if(object->type == OBJ_PAIR){
mark(object->head);
mark(object->tail);
}
}

现在我们的标记已经完成了,我们可以使用markAll()来实现对内存空间的标记

清除

现在最最最重要的一个环节到了,我们需要遍历我们分配的所有对象,并释放那些未被标记到的对象,但是这里有一个问题,按照定义,未标记的对象对我们而言是不可到达的。

VM已经实现了对对象的引用予以,所以哦我们只将对象的指针存储在变量和成对变量中。一旦对象不再被其他变量所引用,虚拟机就完全失去了它,这就发生了内存泄露。为了解决这个问题,VM需要有自己的对象引用,这些引用和用户可见的语义是不同的,换而言之,我们可以自己跟踪它们。

实现的方式也很简单,我们创建一个链表,用来加入我们分配过的所有对象,我们在Object中完成这个过程:

1
2
3
4
5
typedef struct sObject{
// 用来指向对象列表中的下一个对象
struct sObject* next;
...
} Object;

虚拟机则负责跟踪该列表的头部:

1
2
3
4
5
typedef struct {
// 对象列表中的首指针
Object * firstObject;
...
} VM;

newVM()中,我们将头指针初始化为NULL。每次创建对象时,我们都将其添加到列表中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
VM * newVM(){
VM * vm = malloc(sizeof (VM));
vm->firstObject = NULL;
vm->stackSize = 0;
return vm;
}
Object * newObject(VM * vm,ObjectType type){
Object * object = malloc(sizeof (Object));
object->type = type;
object->marked = 0;

//将声明的变量加入对象列表中
object->next = vm->firstObject;
vm->firstObject = object;

return object;
}

现在我们想要删除未标记的对象,只需要遍历列表即可,我们进行清除函数的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void sweep(VM * vm){
Object ** object = &vm->firstObject;
while(*object){
if(!(*object)->marked){
//如果对象未被标记,从链表中移除并释放
Object * unreached = * object;
*object = unreached->next;
free(unreached);
}else{
//如果对象被标记了,就移除标记(为之后的GC准备),然后进行下一步
(*object)->marked = 0;
object = &(*object)->next;
}
}
}

这个代码看起来确实抽象,但是其逻辑实际上十分简单。它遍历整个链表。每当遇到未标记的对象,就释放内存,并将其从链表中移除。执行这个过程中后,我们就删除了所有不可到达的对象。(这里的二重指针真搞人心态,实际上这个object指向当前对象的next地址)。

OK至此为止,我们的垃圾回收器已经完成了,我们将其简单的合并起来:

1
2
3
4
void GC(VM * vm){
markAll(vm);
sweep(vm);
}

但是不止如此,由于内存是有限的,我们需要设置一个对象数量的阈值,一旦超过这个数量就启动GC对内存进行垃圾回收,我们可以通过拓展VM来计算和设置数量:

1
2
3
4
5
6
7
typedef struct {
//当前分配的对象的总数
int numObjects;
//触发GC的对象的总数
int maxObjects;
...
} VM;

然后再VM的创建中初始化他们:

1
2
3
4
5
6
VM * newVM(){
...
vm->numObjects = 0;
vm->maxObjects = INITIAL_GC_THRESHOLD;
return vm;
}

其中INITIAL_GC_THRESHOLD是第一次启动垃圾回收的对象的数量,这个可以自行调整。

当我们每创建一个对象时,我们都需要增加numObject的数量并进行判断,当其到达最大值时进行垃圾回收:

1
2
3
4
5
6
Object * newObject(VM * vm,ObjectType type){
...
//增加对象数量
vm->numObjects++;
return object;
}

当然每当我们使用sweep释放一个对象,我们也应该在程序中减少当前对象的数量:

1
2
3
4
5
6
7
8
9
10
11
12
void sweep(VM * vm){
Object ** object = &vm->firstObject;
while(*object){
if(!(*object)->marked){
...
free(unreached);
vm->numObjects--;
}else{
...
}
}
}

最后我们需要修改GC()来更新最大值:

1
2
3
4
5
void GC(VM * vm){
markAll(vm);
sweep(vm);
vm->maxObjects = vm->numObjects*2;
}

每次回收后我们的剩余活动对象数量会动态更新GC的触发值,如果活动对象变多,就会扩大。如果大量的活动对象被释放,就会自动缩小。

演示

本来是想演示一下这个程序怎么使用的,感觉还是算了,因为这个过程并不是很直观,所以就不在此演示了,不过如果能一直看到这里,想必整个过程始终是心里有数的了,就不在此过多概述。

好吧我还是让AI写了一个测试集:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
int main() {
// 创建一个新的虚拟机实例
VM * vm = newVM();

// 向虚拟机中压入一些整数
pushInt(vm, 10);
pushInt(vm, 20);
pushInt(vm, 30);

// 创建一个对子对象,它包含两个整数对象
Object * pair1 = pushPair(vm);

// 再次压入一些整数
pushInt(vm, 40);
pushInt(vm, 50);

// 创建另一个对子对象,它包含两个整数对象
Object * pair2 = pushPair(vm);

// 弹出一些对象,模拟使用后不再需要的场景
pop(vm);
pop(vm);

// 打印当前虚拟机中的对象数量
printf("Objects before GC: %d\n", vm->numObjects);

// 触发垃圾回收
GC(vm);

// 打印垃圾回收后的对象数量
printf("Objects after GC: %d\n", vm->numObjects);

// 清理虚拟机占用的内存
sweep(vm); // 再次调用 sweep 以确保所有对象都被释放

// 释放虚拟机实例占用的内存
free(vm);

return 0;
}

这是它的测试集,符合我们想要的结果。

源代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
#include <stdio.h>
#include <stdlib.h>

#define STACK_MAX 256
#define INITIAL_GC_THRESHOLD 10

//数据结构定义
typedef enum{
OBJ_INT,
OBJ_PAIR
} ObjectType;

typedef struct sObject{
// 用来指向对象列表中的下一个对象
struct sObject* next;
unsigned char marked;
ObjectType type;
union{
// OBJ_INT
int value;

//OBJ_PAIR
struct {
struct sObject * head;
struct sObject * tail;
};
};
} Object;

typedef struct {
//当前分配的对象的总数
int numObjects;
//触发GC的对象的总数
int maxObjects;
// 对象列表中的首指针
Object * firstObject;
Object * stack[STACK_MAX];
int stackSize;
} VM;

//虚拟机的基本操作与实现
VM * newVM(){
VM * vm = malloc(sizeof (VM));
vm->firstObject = NULL;
vm->stackSize = 0;
vm->numObjects = 0;
vm->maxObjects = INITIAL_GC_THRESHOLD;
return vm;
}

void push(VM * vm,Object * value){
if(vm->stackSize > STACK_MAX){
fprintf(stderr,"Stack overflow");
exit(0);
}
vm->stack[vm->stackSize++] = value;
}
Object * pop(VM * vm){
if(vm->stackSize < 0){
fprintf(stderr,"Stack underflow");
exit(0);
}
return vm->stack[--vm->stackSize];
}

Object * newObject(VM * vm,ObjectType type){
Object * object = malloc(sizeof (Object));
object->type = type;
object->marked = 0;

//将声明的变量加入对象列表中
object->next = vm->firstObject;
vm->firstObject = object;

//增加对象数量
vm->numObjects++;
return object;
}

void pushInt(VM *vm,int intValue){
Object* object = newObject(vm,OBJ_INT);
object->value = intValue;
push(vm,object);
}
Object * pushPair(VM * vm){
Object* object = newObject(vm,OBJ_PAIR);
object->tail = vm->stack[vm->stackSize-2];
object->head = vm->stack[vm->stackSize-1];

push(vm,object);
return object;
}

//垃圾回收机制函数
void mark(Object * object){
if(object->marked) return;
object->marked = 1;
if(object->type == OBJ_PAIR){
mark(object->head);
mark(object->tail);
}
}
void markAll(VM * vm){
for(int i=0;i<vm->stackSize;i++)
mark(vm->stack[i]);
}
void sweep(VM * vm){
Object ** object = &vm->firstObject;
while(*object){
if(!(*object)->marked){
//如果对象未被标记,从链表中移除并释放
Object * unreached = * object;
*object = unreached->next;
free(unreached);
vm->numObjects--;
}else{
//如果对象被标记了,就移除标记(为之后的GC准备),然后进行下一步
(*object)->marked = 0;
object = &(*object)->next;
}
}
}

void GC(VM * vm){
markAll(vm);
sweep(vm);
vm->maxObjects = vm->numObjects*2;
}

int main(){

}