书山有路勤为径,学海无涯苦作舟
日期 | 书名 | 内容 |
---|---|---|
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》 | 对象和类(重读) |
书山有路勤为径,学海无涯苦作舟
日期 | 书名 | 内容 |
---|---|---|
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 | $ ssh-keygen -t rsa -C "3280661240@qq.com" |
在Enter passphrase
处可以输入你的密码(用于生成私钥)
配置公钥文件:获取公钥的内容
1 | $ cat id_rsa.pub |
然后复制内容到这里:
现在我们就添加成功啦。
SSH Key的作用是,Github需要识别处你推送的提交是你的推送的,而不是别人冒充的。同时由于RSA加密的特性,公钥是可以公开的,但是私钥是由自己保管的,从而确保了加密的安全性。在此基础上,我们可以开始远程仓库的使用了。
现在我们在本地已经有了一个版本库,我们再到Github创建一个仓库,让这两个仓库进行远程同步。这样Github上的仓库既可以作为备份,也可以让其他人通过仓库来协作。
首先在Github上面创建一个新的仓库:
一个Github仓库就这样建好了,现在我们将其和我们的本地仓库关联起来。根据Github下面的提示,我们在本地的仓库里面运行这些命令:
1 | $ git remote add origin git@github.com:Ylin07/Learn_Git.git |
添加后,远程库的名字就是origin
,这是Git的默认叫法。下一步,我们将本地库的所有内容推送到远程库上:
1 | $ git push -u origin master |
其中由于,这是第一次关联,我们需要验证公钥指纹,然后将身份添加到~/.ssh/known_hosts
中,同时需要输入密钥验证,这里我用分割线把这一部分区分出来。下面则是我们将本地仓库推送上去的信息。
由于远程库是空的,我们第一次推送master
分支,使用了-u
参数,Git不但会把本地的master
分支推送上去,还会把本地的master
和远程的master
分支关联起来,这样在之后的推送和拉去就可以简化命令。
然后我们就可以在Github页面中看到远程库的内容和本地是一样的
从现在开始,只要本地作了提交,就可以通过命令:
1 | $ git push origin master |
把本地的master
分支的最新修改推送到GIthub,现在我们就有了完整的分布式版本库。
如果添加的时候地址写错了,或者是想删除远程库,可以使用git remote rm <name>
命令。使用前,我们先使用git remote -v
来查看远程库的信息:
1 | $ git remote -v |
然后根据名字删除,比如删除origin
:
1 | $ git remote rm origin |
此处的删除实际上是解除了本地和远程的关联状态,并不是删除了远程库。远程库本身并没有改动,如果想要删除远程库,应该到Github后台删除。
上次我们讲了现有本地库,再有远程库的时候,如何关联远程库。现在我们从头开始,假如我们从远程库开始克隆该怎么做呢?
我们把这个网址的项目给clone
下来8086
1 | $ git clone git@github.com:Rexicon226/8086.git |
OK,然后看看文件夹,已经被远程库的内容已经被拉下来了:
1 | $ cd 8086 |
当然我们还可以使用其他的方法,比如https协议,只不过这个对网络环境有一定的要求。
1 | git clone https://github.com/Rexicon226/8086.git |
现在我们就掌握了对于远程仓库的基本操作啦。
当你和别人共同开发一个项目时,你们可以各自创建一个分支,不同的分支拥有自己的进度,互不打扰。再各自的项目完成之后,再将分支合并,从而实现同时开发的效果,这就是Git的分支管理功能。
在版本回退中我们知道,Git把提交串成一条时间线,这个时间线就是一个分支。当目前为止,我们只有一个分支,这个分支就叫主分支master
,而其中的HEAD
指针并不直接指向提交的,它指向的是master
,而master
指向的是提交,所以我们说HEAD
指向的是当前的分支。
一开始的时候,master
分支是一条线,Git用master
指向最新的提交,再用HEAD
指向master
,就能确定当前分支,以及当前分支的提交点:
1 | HEAD --> master |
每次提交master
分支就向前移动一步,这样随这不断的提交,master
分支也会越来越长。
当我们创建新的分支new
时,Git新建了一个指针叫new
,指向master
相同的提交,再把HEAD
指向new
,就表示当前分支在new
上:
1 | master |
由此可以看出,Git创建一个分支很快,实际上就是增加了一个new
指针,然后改变一下HEAD
的指向
不过接下来,对于工作区的修改与提交就是针对new
了,比如提交一次之后会变成这样:
1 | master |
假如我们在分支new
上的工作完成了,就可以把new
合并到master
上。Git怎么合并呢,实际上就是将master
移动到和new
指向相同的版本上,然后将HEAD
指向master
1 | HEAD --> master |
合并完成之后,甚至可以删除new
分支,我们直接将其new
指针删除既可,这样就只剩下一个mater
分支:
1 | HEAD --> master |
这样相当于用分支的功能完成了提交,这样更加安全。
现在我们来进行尝试:
首先创建一个new
分支,然后切换过去
1 | $ git checkout -b new |
git checkout
命令加上-b
参数表示创建并切换,相当于以下两个命令的组合
1 | $ git branch new |
git branch
命令会列出所有的分支,当前分支前会有一个*
号。然后我们在new
分支上修改后正常提交:
1 | $ git add readme.txt |
现在dev
的分支工作完成,我们切换回master
分支,然后再查看readme.txt
:
1 | $ git checkout master |
然后我们打开readme.txt
发现原来的修改怎么不见了。因为刚刚提交的修改在new
分支上,此时master
分支的提交点并没有变:
1 | HEAD --> master |
现在我们将new
分支的工作成果合并到master
分支上:
1 | $ git merge new |
git merge
命令用于合并指定分支到当前分支。合并后,再查看readme.txt
的内容,可以看到现在和最新提交是一样的了。
注意到上面的Fast-forward
信息,它的意思是这次合并是“快进模式”,也就是把master
指向dev
的当前提交,所以很快
合并之后,我们将new
分支删除,并查看branch
,只剩下了master
:
1 | $ git branch -d new |
由于创建,合并和删除分支非常快,所以Git更加鼓励使用分支完成任务,然后再删除分支,这样比直接在master
上工作的效果是一样的,但是过程更加的安全。
切换分支,除了使用git checkout <branch>
,还可以使用switch
来实现:
new
分支,可以用:git switch -c new
master
分支,可以用:git switch master
有时候合并也会遇到各种冲突,现在我们手动制造一个冲突,我们创建一个新的分支n1
:
1 | $ git switch -c n1 |
最后一行加个01234
,然后提交修改
1 | $ git add readme.txt |
然后切换回master
分支,然后对readme.txt
做不一样的修改,并提交:
1
2
3
4$ git add readme.txt
$ git commit -m "56789"
[master 38fe2c0] 56789
1 file changed, 1 insertion(+)
现在master
和n1
分支各自都分别有新的提交:
1 | +-->[ ] <-- master <-- HEAD |
这种情况下,Git没办法快速合并,只能视图把各自的修改合并起来:
1 | $ git merge n1 |
结果提示了冲突,我们需要手动解决冲突后再提交,我们可以用git status
告诉我们冲突的文件:
1 | $ git status |
我们打开readme.txt
看看:
1 | Hello Git!!! |
Git
用<<<<<<<
,=======
,>>>>>>>
标记出不同分支的内容,我们修改后再保存
我们修改之后再保存:
1 | Hello Git!!! |
再提交:
1 | $ git add readme.txt |
现在我们的版本库变成了这样:
1 | +-->[ ]-->[ ] <-- master <-- HEAD |
我们可以用带参数的got log
可视化的看到我们分支的合并情况:
1 |
|
解释以下标签的意思:
--graph
:以字符可视化的方式打印分支流程--pretty=oneline
:以一行压缩打印,不显示id之外的信息--abbrev-commit
:简略id信息,只显示一部分合并之后,我们删除n1
分支:
1 | $ git branch -d n1 |
通常合并分支时,Git会优先使用Fast forward
的模式,但这种模式下,删除分支,会丢失分支信息
如果我们要强制禁用Fast forward
模式,Git就会在merge时生成一个新的commit,这样就从分支历史上可以看出分支信息,下面我们尝试一个--no-ff
方法的git merge
:
首先创建一个分支,并修改内容,然后提交修改:
1 | $ git switch -c dev |
然后我们切换回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 | $ git log --graph --pretty=oneline --abbrev-com |
可以看到不用Fast forward
模式,merge后就像这样:
1 | +-->[ ] <-- master <-- HEAD |
在实际开发种,我们应该明确几个基本原则,进行分支管理:
master
分支应该稳定的,仅用来发布新版本,不能在上面干活dev
分支上干活,只有特定版本可以发布时,我们再将dev
和master
分支进行合并dev
分支合并因此,团队协作更像是这样的:
朋友圈里看到同学用Git,觉得图形化还是很帅的。加上最近刚好有需求,所以就来系统的学习一下git的使用。
这里参考的教程是:简介 - Git教程 - 廖雪峰的官方网站
先从git在本地的简单使用开始学习一下吧,明天再接着讲讲git的远程使用
git是一款分布式的版本管理器。
分布式的意思就是一个每个开发者人的工作区就是一个完整的版本库。你的修改,和分支都可以再本地仓库里面完成,然后再推送到远程仓库中。不同的开发者之间通过向远程仓库push修改,或者从远程仓库pull操作,从而实现同步代码。
这里我选择在WSL中使用git,可以通过sudo apt-get install git
下载git
然后我们需要对我们git进行配置,我们需要初始化自己的信息:
1 | $ git config --global user.name "Your Name" |
这里的--global
参数指的是对本机器的所有仓库都使用这个信息,当然你也可以在不同的仓库中使用不同的用户信息。
我们刚刚所说的仓库实际上就是版本库(Repository),之后我们会频繁的看到这个单词。在这里,你可以讲仓库理解成一个目录,里面的内容被git管理起来(被其跟踪修改,记录历史,或者在之后用来”还原”)
首先我们创建一个版本库作为我们的练习:
1 | $ mkdir learn_git |
然后使用git init
对我们的仓库进行初始化:
1 | $ git init |
然后我们可以在当前目录下找到一个.git
目录,不过它是隐藏的,所以我们使用ls -ah
来找到它,这个目录用来跟踪管理版本库的。
这里我们需要搞清楚一件事,就是所有的版本控制器,只能管理文本文件,无法管理二进制文件。你可以管理文本文件,哪里被修改了,哪里增加了,但是你不能追踪一张图片被做了什么修改。
现在我们向我们的鹅目录下写一个readme.txt
文件:
1 | Hello Git! |
接下来我们将其提交到我们的仓库,需要以下过程:
git add readme.txt
git commit -m "..."
(-m
后面用来添加改动记录,也可以不加)这个是加入仓库后的结果:
1 | [master (root-commit) 1428371] wrote a read file |
其中1 file changed
是因为我们添加了一个文件;1 insertion(+)
是因为我们添加了一行内容
这里的git add
和git commit
之所以分开的原因后面会解释。现在我们只需要知道,add
可以添加很多次很多内容,commit
则是将添加的内容全部提交上去
我们刚刚成功的添加并提交了readme.txt
文件,现在我们继续工作,向其中添加内容,改成:
1 | Hello Git!!! |
现在运行git status
命令看看结果:
1 | $ git status |
通过这个命令我们可以时刻掌握仓库当前的状态,例如上面的信息告诉我们,readme.txt
的内容被修改了,但是我们还没有提交该修改。
可是我们只知道文件被修改了,但我们并不知道哪些地方被修改了,所以我们使用git diff
来查看一下:
1 | $ git diff readme.txt |
顾名思义这个指令是用来查看difference的,显示的格式是Unix通用的diff格式,注意其中的@@ -1 +1,2 @@
。-
后面的两个值分别是原始文件的差异起始行和差异行数,+
后面的两个值分别是修改文件的差异起始行和差异行数。这些信息常用于定位文件的修改内容和修改范围。知道文件哪些地方被修改之后,我们再次将我们的readme.txt
提交到我们的仓库中:
1 | $ git status |
我们再次运行git status
看看当前仓库的状态,我们看到我们将要提交的修改的内容有readme.txt
现在我们将其提交git commit
:
1 | $ git commit -m "add my love" |
然后再看看仓库,git status
:
1 | $ git status |
Git告诉我们,当前没有要提交的内容,且工作目录是干净的
我现在已经了解了怎么修改,我们尝试对其进行再一次的修改和提交:
1 | Hello Git!!! |
然后再次提交:
1 | $ git add readme.txt |
这个过程在实际的项目中会重复很多次,每当文件修改到一定的程度我们将其commit
,即保存一个快照。一但我们的文件出现了错误,我们就可以用Git来回到之前的版本,接下来我们将演示这个过程。
现在我们的Git仓库里一共有三个版本,但是我们并不记得每次改动了哪些内容。我们现在可以使用git log
命令来查看我们的版本控制系统中的历史记录:
1 | $ git log |
git log
命令显示从最近到最远的提交日志,如果你认为输出的信息太多,可以试试git log --pretty=oneline
参数:
1 | $ git log --pretty=oneline |
然后你会看到前面一大堆看不懂的东西,这个是commit id
,每一次提交,都有对应的id,原理是SHA1计算
现在我们想要把readme.txt
回到上一个版本,怎么办?首先需要知道,当前是什么版本(就是最新提交的14283…),当前的版本是HEAD
,上一个版本是HEAD^
,上上个版本就是HEAD^^
,那么上n个版本就是HEAD~n
。
现在哦我们将当前版本add sorry
回退到上一个版本add love
,我们使用git reset
:
1 | $ git reset --hard HEAD^ |
--hard
参数会回退到上个版本的已提交状态,--soft
会回退到上个版本的未提交状态,--mixed
会回退到上个版本已添加但未提交的状态,这里我们使用--hard
我们可以继续回退,但是我们现在也要面临一个问题,我们没办法回去了,有办法吗?有的兄弟,有的。我们先看下log
:
1 | $ git log --pretty=oneline |
发现之前的commit id
没了,这咋办呢?我们使用git reflog
查看HEAD指针的历史记录:
1 | $ git reflog |
我们可以看到da91da7
是先前版本的commmit id
,我们使用git reset
返回这个版本:
1 | $ git reset --hard da91da7 |
我们成功的回到了这个版本(这里版本号不需要写全,Git会自己去查找),就这样我们实现了git的版本控制
Git版本回退的速度特别快,你可能会好奇这是为啥,这是因为Git内部有一个指向当前版本的指针HEAD
,当你回退版本的时候,仅仅只是把HEAD指向了前一个版本。然后顺便把工作区的文件更新了。
这个概念是Git特有的一个概念,我们先对其进行解释:
.git
,这个不算工作区,而是Git的版本库。其中版本库里有很多东西,其中一个比较重要的内容就是stage
(也叫index
),也就是暂存区。以及Git为我们自动创建的一个分支master
,以及指向master
的一个指针HEAD
前面我们讲到,将文件加入Git版本库中的时候,是分两步执行的,现在我们可以解释了 :
git add
实际上就是将文件添加到暂缓区git commit
实际上就是将暂存区中的所有内容提交到当前分支中创建版本库的时候,Git为我们创建了一个mater
分支,也就是当前的git commit
都是向master
分支上提交更改
现在,我们带着这个思考再次进行这个过程,我们修改一下readme.txt
并添加一个LICENSE
文件:
1 | $ git status |
我们可以看到,readme.txt
被改动的信息,还有LICENSE
被告知没有被添加过。我们用git add
添加后再次查看:
1 | $ git status |
现在我们的暂存区变成了这样:
然后再将其提交,并查看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
现在的我们的版本库变成了这样,暂存区没有内容了:
讲到这里就不得不介绍一下,Git相较于其他版本控制系统的优越之处。这是因为Git管理的不是那文件内容,而是对于文件的修改内容。这样极大的减少了系统的管理成本。
我们尝试以下过程,以验证我们提交的是修改,而不是文件内容:
第一次修改后,将其添加至缓冲区:
1 | $ git add readme.txt |
然后我们再进行一次修改,接着我们直接提交到仓库:
1 | $ git commit -m "git tracks change" |
但是我们发现我们的第二次修改并没有被提交,这就验证了我们的想法。我们提交的是第一次的修改,并不是最终的文件的样子,所以证明得到:提交的是修改而不是文件。
现在我们可以使用git diff HEAD -- readme.txt
查看工作区和版本库中的最新版本的区别:
1 | $ git diff HEAD -- readme.txt |
我们再次将其提交,git add
–>
git commit
有时候我们会犯错,如果错误发现的及时我们可以手动纠正回来,但是纠正前使用git status
你可以看到以下信息:
1 | $ git status |
这里提到可以使用git restore <file>...
恢复工作区到暂存区的状态。
1 | $ git status |
当然你也可以进一步使用参数git restore --source=HEAD~n -- readme.txt
来指定将工作区恢复到哪次提交的版本
当然,我们还可能遇到另外一种情况,就是我们已经将错误git add
到了暂存区,我们在commit
之前发现了这个错误,我们该怎么修改呢?我们使用git status
:
1 | $ git status |
Git
提醒我们可以使用git restore --staged <file>...
来撤销最近一次的git add
内容。
1 | $ git status |
我们成功的撤销了暂存区的内容,接着我们再次撤销工作区的修改:
1 | $ git restore readme.txt |
现在我们实现了对工作区和暂存区的撤回操作
即版本回退,见上。
在git中,删除也是一个修改操作,我们可以进行尝试。
我们向创建一个新文件test.txt
并提交
1 | $ git add test.txt |
然后我将其从工作区中删除,再使用git status
查看状态:
1 | $ rm test.txt |
Git提醒我们这个文件被删除了,且指导我们可以使用git rm
将其从版本库中删除,并且git commit
:
1 | $ git rm test.txt |
现在文件就从版本库中删除了。
当然也有可能这个文件被你误删了,如果你上一次提交了这个文件的i需改,我们可以使用git restore --source=<commit_hash> <file_name>
恢复到上一次提交的版本,不过还是会丢失你之后额外修改的内容。
至此,我们对于Git的基本使用就已经基本掌握了
今天是2025年4月17号,我打算从今天开始我未来的每月总结。
其实月中也挺好的,先忙半个月,然后看看有没有效果。然后再指正接下来半个月的要做什么。
我也不知道应该反省什么,我的上个月挺糟糕的。月初还算高兴吧,后面我女朋友的寝室出了点事情,她的室友意外去世了。我女朋友因为这个心理状态也不是很好,接下来的半个月,我一直在陪她。中途我和室友出去玩了一趟,从宣城,到马鞍山,再到南京。回来之后,我的心情挺好的,我的女朋友心情也好受了很多。但是后面她突然开始一直发烧,白天低烧,晚上高烧,一直到清明节之后。这段时间过的挺煎熬的,我自己也很多事情,很多时候我没有很好的照顾到她。她也很理解我。
然后再是这个月,我刚结束了图形学的基本学习。然后休息了两天,一直到现在,我觉得我好累,有点迷茫了。现阶段要学习的和要处理的东西特别多,所以我打算停下来缕一缕。
接下来打算学习的内容比较多,现在不知道学什么好,可以大致的列一下:
我现在有点搞不清学习顺序了,都做的话是不太可能的,所以现在慢慢理一理。
其中算法学习可以拖延至暑假,学校课程学习可以放到五六月开始,项目学习可以在计算机学习之后的一段空余时间继续。嗯,然后C++必须尽快学完,这是项目练习和算法学习的基础。
这么看来优先是这样的:
然后这个月我打算上完蒋炎岩的操作系统课程,看看能不能写出一个简单的操作系统。暂时就这些吧,然后是开始准备上高数课,不然进度上有点跟不上,英语记得的话背点单词吧。
关于我的心情,最近也是好多了,最近开始玩泰拉瑞亚,慢慢玩吧,要是能通关就好了,不急呐。还有怪物猎人,记得的话就玩一下呗。
马上五一劳动节,打算没事就学习,然后陪陪女朋友,和好兄弟出去玩一会。
就这样吧,写一写还是很好玩的。
两个星期过去了,我们的图形学入门之旅也是迎来了终章。
最后就以一个完美的作品来作为纪念我们图形学学习的短暂结束。
我们设置main函数:
1 | #include "rtweekend.h" |
两个星期的学习,都在这张图片之中了
今日收官之战,图形学之旅差不多到此为止了
我们的相机目前是固定视角的,比较单调。由于程序的复杂性,所以在这里我们最后实现它。首先,我们需要为我们的相机添加视场(fov)效果。这是从渲染图像一边到另外一边的视觉角度。由于我们的图像不是正方形视图,所以水平和垂直的fov是不同的。这里我们选择开发垂直fov。我们用度数来指定它,然后再构造函数中将其转换为弧度。
我们继续保持从原点发出的光线,指向z轴上的平面,然后使h与这个距离的比例保持一致:
其中theta是我们的视野,即fov,这里表示h = tan(theta/2)
我们将其应用到我们的相机类中:
1 | class camera{ |
这个原理就是实现广角,图像的宽高比不变,但是视口的大小改变了,使得图像有拉伸压缩的视觉效果
我们用广角来渲染一下我们之前的图片试试,发现渲染出来的效果和之前是一样的,这是为什么呢? 因为之前设置的视口高度为2.0,而焦距为1.0,所以计算可以得到当时的视角也是90°,自然和现在是一样的了,同理我们可以通过缩小垂直视野来看到更远处的东西,这就是放大缩小的原理
如果我们想要任意摆放,获得任意的视角,我们首先需要确定两个点,一个是我们放置计算机的点lookfrom
,还有一个是我们想要看到点lookat
然后我们还需要定义一个方向作为摄像机的倾斜角度,即lookat-lookfrom轴的旋转。但其实还有一种方法,我们保持lookfrom
和lookat
两点不变,然后定义一个向上的方向向量,作为我们的摄像方向。
我们可以指定任何我们想要的向上向量,只要它不与视图方向平行。将这个向上向量投影到与视图方向垂直的平面上,以获得相对于摄像机的向上向量。我们将其命名为vup
即向上向量。我们可以通过叉乘和向量的单位化,得到一个完整的正交归一基,然后我们就可以用(u,v,w)来描述摄像机的方向了。
这里u指的是摄像机右侧的单位向量,v指的是摄像机向上的单位向量,w指的是视图方向相反的单位向量,摄像机中心位于原点
之前我们需要让-Z和-w在同一水平面,以实现水平摄像叫角度,现在我们只需要指定我们的向上向量vup指向(0,1,0)就可以保持摄像机的水平了
我们将这些功能加入相机类中:
1 | class camera{ |
然后我们在main函数中调整我们的视角:
1 | int main(){ |
然后我们发现渲染出来的图片不是很清晰,我们可以通过修改垂直视野来放大
1 | cam.vfov = 20; |
太好看了
最后,我们还需要实现摄像机的一个特性:散焦模糊。在摄影中,我们把这种效果称之为景深。
在真实相机中,散焦模糊的产生是因为相机需要一个较大的孔(光圈)来收集光线,而不是一个针孔。如果只有一个针孔,那么所有物体都会清晰地成像,但进入相机的光线会非常少,导致曝光不足。所以相机会在胶片/传感器前面加一个镜头,那么会有一个特定的距离,在这个距离上看到的物体都是清晰的,然后离这个距离越远,图像就越模糊。你可以这么理解镜头:所有从焦点距离的特定点发出的光线——并且击中镜头——都会弯曲回图像传感器上的一个单一点。
在这里我们需要区分两个概念:
不过这里为了简化模型,我们将焦点平面和视口平面重合。
“光圈”是一个孔,用于控制镜头的有效大小。对于真正的摄像机,如果你需要更多的光线,你会使光圈变大,这将导致远离焦距的物体产生更多的模糊。在我们的虚拟摄像机中,我们可以拥有一个完美的传感器,永远不需要更多的光线,所以我们只在想要产生失焦模糊时使用光圈。
真实的相机镜头十分复杂,有传感器,镜头,光圈,胶片等…
实际上,我们不需要模拟相机内的任何一个部分,这些对于我们而言太过复杂,我们可以简化这个过程。我们将其简化成:我们从一近似平面的圆形”透镜”发出光线,并将它们发送的焦点平面的对应点上(距离透镜focal_length
),在这个平面上的3D世界中的所有物体都处于完美的焦点中。
我们将这个过程展示出来:
没有散焦模糊时,所有的场景光线都来自相机中心(lookfrom
)。为了实现散焦模糊,我们在相机中心构造一个圆盘。半径越大,散焦模糊越明显。你可以把我们的原始相机想想象成一个半径为0的散焦圆盘,所以完全不模糊。
所以我们将散焦盘的设置作为相机类的一个参数。我们将其半径作为相机系数,同时还需注意一点,相机的焦点距离也会影响散焦模糊的效果。此时,为了控制散焦模糊的程度,可以选择以下两种方式:
由于我们将从失焦盘中选择随机点,我们需要一个函数来完成这个任务random_in_unit_disk()
这个和我们在random_in_unit_sphere()
用到方法一样,只不过这个是二维的:
1 | //vec3.h |
现在我们更新相机,加入失焦模糊的功能:
1 | class camera{ |
现在我们的相机具备了景深的效果,然我们来渲染试试效果吧:
Good,到此为止,我们的相机终于完成了!
继续冲
乍一听可能感觉很厉害,但实际上水,玻璃,钻石一类的材料都属于介电质,他们通常有以下特点:
当光线击中他们时,会分成反射光线和折射(透射)光线。我们将通过随机选择的反射换和折射来处理这个情况,并确保每次交互只生成一个光线。
当光线从一种材料的周围环境进入该材料本身,如(玻璃和水)时,会发生折射,光线会弯曲。折射光线的弯曲程度由材料的折射率决定。通常,折射率n是一个描述光线从真空进入材料时弯曲程度的单一值。当一种透明材料嵌入另一种透明材料中时,可以用相对折射率来描述折射:物体的折射率/环境的折射率
。如渲染一个水下的玻璃,那么其有效折射率为玻璃的折射率/水的折射率
折射一般由斯涅尔定律来描述:
这里的符号我不好用Latex打出来,所以折射率用eta来描述,并且这里结合一张图来描述:
为了确定折射光线的方向,我们需要解出sin(theta’):
1 | sin(theta0) = eta/eta0 * sin(theta) |
我们可以将折射光线分解成垂直法线方向和平行法线方向:
1 | R = R_parallel + R_vertical |
根据斯涅尔定理,折射光线的垂直分量与入射光线的垂直分量也成比例:
1 | R_v0 = eta/eta0 * R_v |
所以可以计算出
1 | R_p = (R*n)*n //这里的n已经单位化了,所以不用除|n|,直接*n即可 |
由于单位向量下,|R|^2 = |R_p|^2 + |R_v|^2
,我们有:
1 | R_p0 = -sqrt(1 - |R_v0|)*n |
我们可以写出向量的折射函数:
1 | //vec3.h |
在此基础上,我们可以创建出我们的介电质材料类:
1 | class dielectric : public material{ |
然后我们更改main函数重新渲染看看效果:
1 | int main(){ |
可以看到中间一个怪怪的就是我们的玻璃球体,现在它只有折射属性,所以看起来怪怪的,中间的小黑点则是因为其光线追踪到的未被遮挡的阴影。
如果介电材料只是折射到话,也会遇到一些问题,对于一些光线角度,它的计算并不符合斯涅尔定理。如果光线以较大的入射角进入交界处,可能会以大于90°的角度折射出来,这显然是不可能的,所以这里我们将要用到我们高中所学的知识——全反射,来解决这个问题。
至于判断什么时候计算,我们计算一下折射角度的sin值,如果sin值大于1,就说明发生全反射。我们就不用折射函数来计算,用反射函数来计算出射光线。我们可以写出如下代码:
1 | bool scatter(const ray& r_in,hit_record& rec,color& attenuation, ray& scattered) const override { |
我们现在采用这个新的dielectric::scatter()
函数渲染先前的场景,会发现没有任何变化。这是因为给定一个折射率大于空气的材料的球体,不存在入射角会导致全反射。这是由于球体的几何形状,入射和出射的角度经过两次折射还是一样的。
所以我们这里模拟空气的折射率和水一样,然后把球体的材料改为空气,所以我们设置它的折射系数为index of refraction of air / index of refraction of water
,然后我们渲染试试:
实际生活中,光线击中透明材质表面时,一部分光会反射,还有一部分光会折射,这个比例取决于入射角和两种介质的折射率。这个现象叫做菲尼尔效应,其严格的计算十分复杂,但好在我们有一个名为Schlick近似的计算方式,它是简化的替代方案,而且十分简便。我们可以看下它的内容:
我们在此基础之上可以改进我们的dielectric
类:
1 | class dielectric : public material{ |
对比一下:
左边确实更加逼真了。
我们建模一个空心玻璃球。这是一个由厚度的球体,里面有一个空气球。光线穿过这个物体,先击中外球,然后折射,然后穿过球内的空气。然后又击中球的内表面,折射,再击中外球的内表面,最后返回场景大气中。
我们设置外球,使用标准玻璃建模,折射率为1.50/1.00
(从空气射入玻璃)。内球不同,内球使用空气建模,折射率设置为1.00/1.50
(从玻璃射入空气)
我们设置一下main函数,再次渲染:
1 | int main(){ |
感觉不错啊,今天就到此为止吧
继续图形学的学习,我打算在下一周左右结束图形学的学习,因为要期中考试了(晕),现在赶赶进度
如果想让不同的物体拥有不同的材质,我们可以设置一个通用的材质类,具有许多参数。或者我们可以有一个抽象的材质类,封装特定材质的独特行为,这里我们使用第二种方式,因为这样便于我们更好的组织代码,设置这么一个类,对于不同的材质,我们需要做两件事:
在此基础之上,我们可以定义出我们的抽象类:
1 | #ifndef RENDER_C___MATERIAL_H |
hit_record
的设置目的是为了避免一大堆的参数,所以我们设置一个封装的类型,将信息参数放入其中。当然,由于hittable.h
和materials.h
需要在代码中能够引用对方的类,为了避免他们的循环依赖,我们向hittable
文件头中添加class material
来指定类的指针。
1 | class material; |
hit_record
将一堆参数放入类中,然后作为一个组合发送。当光线击中一个表面是,hit_record
中的材料指针被设置为球体在main
中给定的指针材料。且当ray_color()
获取hit_record
时,它可以调用材料指针的成员函数来找出散射(如果有的话)
现在我们需要设置球体sphere
类:
1 | class sphere: public hittable{ |
反射率是我们接下来需要关注的一件事情,反射率和材质 颜色有关,也会随入射光线方向变化
而我们先前的Lambertian 反射,它的反射有三种情况:
这里为了程序的简易性,我们选择第一种情况,来实现我们的Lambertian 材料:
1 | class lambertian: public material{ |
如果你仔细阅读会发现,我们使用的是random_unit_vector
来生成一个随机的向量,这个向量可能和法线是等大反向的,从而导致零散射方向向量的情况,导致后许发生各种不良的情况。所以我们需要拦截这种可能性
所以我们创建一个新的向量方法——该方法返回一个布尔值,判断各个方向的维度会不会趋近0:
1 | bool near_zero() const { |
然后更新我们的Lambertian反射:
1 | bool scatter(const ray& r_in,hit_record& rec,color& attenuation, ray& scattered) const override{ |
对于抛光的金属,光线不会随机的散射,而是对称的反射:
这个过程我们可以用向量来实现计算:
我们可以写出一下程序来计算反射函数:
1 | //vec3.h |
然后我们用这个函数实现我们的金属材质:
1 | class metal: public material{ |
我们接下来修改ray_color()
函数以应用更改:
1 | color ray_color(const ray& r,int depth, const hittable& world) const { |
然后接着更行sphere的构造函数以初始化材质指针mat
:
1 | class sphere : public hittable { |
现在我们向场景中添加我们的金属球体
1 | int main(){ |
成功渲染了出来,看:
我们的金属球体太过完美,但是现实中我们往往也会看到各种磨砂材质的金属,所以在这里我们要引入一个新的变量fuzz
,来随机化反射方向。我们使用一个随机点,以原始起点为中心,来模糊反射的光线。
我们用fuzz
来决定模糊球体的大小,模糊球体越大,反射效果就越明显。当模糊球体较大或光线几乎平行于表面时(称为掠射光线),计算出的反射光线可能会指向物体内部,即在物体表面下方。对于这种情况,可以选择简单地吸收这些光线,即认为它们没有从物体表面反射出去。
为了确保模糊球体相对于反射光线的尺度是一致的,需要对反射光线进行归一化处理,即调整其长度为1。这样做可以确保无论反射光线的长度如何变化,模糊球体的尺度都是相对于光线方向的,而不是其长度。
于是我们可以更改以下程序以实现这个功能:
1 | class metal: public material{ |
然后调整一下我们的main函数:
1 | int main(){ |
渲染出来的是这样的:
可以看到左边有明显的模糊感
中途歇了一段时间写物理作业,然后昨天学了下垃圾回收。现在继续开始我们的图形学学习。
现在我们有了物体和每个像素的多个光线,现在我们可以尝试制作更加逼真的材质了。我们先从我们的漫反射材质开始(也称为哑光材质)开始。不过这里我们将几何体和材质分开使用,这使得我们可以将一种材质应用于多种物体,或者将多种材质应用于一种物体。这样分开使用的方法更加灵活也更加容易拓展,所以我们选择这种方式来实现我们的材质。
一个漫反射的物体不会发出自己的光,它会吸收周围的环境的颜色,然后通过自己固有的颜色来调节。从扩散表面反射的光线方向是随机的,我们向两个漫反射材质之间发射三束光线,他们的行为会有所不同
当然,他们有可能会被吸收,也有可能会被反射。表面越暗,说明光线被吸收的可能性更大(黑色说明光线被完全吸收了)。实际上我们我们可以用随机化方向的算法产生看起来哑光的材质,最简单的实现就是:一个光线击中表面,有相等的机会向任何方向弹射出去
这样的漫反射材质是最简单的,我们需要实现一些随机反射光线的方法,我们向vec
类中再额外实现几个函数,以完成能够生成任意随机的向量:
1 | class vec3 { |
现在我们需要操作这个随机向量,以确保我们的向量生成在外半球。我们用最简单的方法来实现这个过程,即随机重复生成向量,直到生成了满足我们需求的标准样本,然后采用它,实现它的具体方法如下:
我们开始这个算法的具体实现,首先在包围单位球体的立方体内随机生成一个点(即x,y,z都在[-1,+1]内)。如果这个点在单位球体之外,则重新生成一个点,直到找到一个在单位球体内的一个点:
然后我们将其单位化
我们先实现这个功能吧:
1 | //vec3.h |
实际上这里还会有一点小问题,我们需要直到。由于浮点数的精度是有限的,一个很小的数在平方后可能会向下溢出到0。也就是说,如果三个坐标都足够小(非常接近球心),向量在单位化操作下可能会变成[+-无穷,+-无穷,+-无穷]
。为了解决这个问题我们需要设置一个下限值,由于我们使用的是double
,所以在这里我们可以支持大于1e-160
的值,所以我们更新一下程序:
1 | //vec3.h |
现在我们计算得到了单位球面上的随机向量,需要将其与表面法线比较,以判断其是否为位于正确的半球
1 | //vec3.h |
接下来我们需要将其应用到上色函数中,这里的话我们还需要注意一点,就是光线颜色的反射率,如果反射率为100%,那么我们看到的都是白色的,如果光线反射率是0%,那么光线都被吸收,我们看到的物体是黑色的。这里我们设置我们的光线反射率为50%,并将其应用到我们的ray_color()
中:
1 | color ray_color(const ray& r, const hittable& world) const { |
然后我们可以看到渲染出来的图片:
注意,我们的ray_color
函数是递归的,我们却不能确保它何时停止递归,也就是它不再击中任何东西的时候。当情况比较复杂的时候可能会花费很多时间或者是栈空间,为了防止这种情况我们需要限制这个程序的最大递归深度,我们将其作为camera
类的一个属性,并且更改我们的ray_color()
函数:
1 | class camera{ |
同时我们可以通过更新main
函数来更改我们的深度限制:
1 | int main(){ |
渲染出来的效果差不多
我们这里还需要解决一个问题,当我们计算光线与表面的交点时,计算机会尝试准确的计算出它的交点,但是由于浮点数的误差,我们很难准确的计算出来,导致交点会略微偏移,这其中就有一部分的交点会在表面之下,会导致从表面随机散射的光线再次与表面相交,可能会解出t = 0.000001,也就是在很短的距离内,再次击中表面。
所以为了解决这个问题,我们需要设置一个阈值,当t小于一定程度的时候,我们不将视作有效的命中:
1 | color ray_color(const ray& r,int depth, const hittable& world) const { |
我们在此基础上再尝试渲染一次,看看效果:
右边是修复后的效果,我们可以明显感受到其变得更加明亮,更加真实
我们先前用的是随机的光线来实现漫反射,这样的话会虽然可以正确的渲染出我们的图像,但是缺少了一点真实感,在实际的漫反射中,反射光线遵循一定的规律(应该是在图形学中通常使用Lambertian定理来实现漫反射)
Lambertian反射的核心思想是反射光的亮度和观察的方向无关,而是和入射光线和表面法线的夹角有关,实际上他们之间存在I = I0*cos(/theta)
的关系。
为了在光线追踪中模拟Lambertian分布,我们可以通过移动单位球的方式以实现这个过程,我们向随机生成的单位向量添加一个法向量,来实现这个移动。这么说可能比较抽象,可以看下以下图片:
我们将单位球沿着法线方向移动生成一个新的单位球,此时我们随机分布的点位是大致符合Lambertian反射的,具体的内容可以自己去尝试Lambertian分布的搜索。
这个过程看起来很复杂,实际上实现起来十分简单:
1 | color ray_color(const ray& r,int depth, const hittable& world) const { |
然后我们再次尝试渲染:
右图是经过Lambertian反射后生成的渲染图像,可以注意到经过Lambertian反射的设置之后,右图的阴影变得更加明显。这是因为散射的光线更多的指向法线,使其更加击中,对于交界处的光线,更多的直接向上反射了,所以在球体下方的颜色会更暗淡。
大多数显示设备(如显示器、电视和投影仪)的亮度输出与输入信号的关系是非线性的。这种非线性关系称为伽马(gamma),通常在2.2左右。这意味着,如果直接将线性空间(即未进行伽马校正的空间)的像素值发送到显示设备,显示出来的图像会显得比预期的要暗。
人眼对亮度的感知也是非线性的。我们的眼睛对暗部细节比亮部细节更敏感。通过伽马校正,可以使图像的亮度分布更符合人眼的感知特性,从而在视觉上获得更好的效果。
这里我们需要使用一个简单的gamma矫正模型,
我们编写我们的write_color()
函数以实现从线性空间到伽马空间的变换:
1 | inline double linear_to_gamma(double linear_component){ |
现在我们对我们的write_color()
进行了伽马矫正,现在我们再来看看效果:
左边的是经过伽马矫正之后的图片,确实更好看了一点,哈哈。
大佬的技术博客,拜读了一下,理解的七七八八,做一个记录和研究吧。
文章地址:Baby’s First Garbage Collector – journal.stuffwithstuff.com
在计算机运行的过程中,会不断的分配和释放内存。尤其是在一些低级的语言中,内存的分配和释放都需要程序员手动分配。如果不对内存加以分配和管理,就会导致系统资源耗尽,从而发生内存泄露。或者如果错误的释放了正在使用的内存空间,那么又会导致系统崩溃。所以一个合理的垃圾回收机制,可以自动识别并回收不再使用的内存,减轻程序员的负担。
这里我们将要使用的垃圾回收机制是 标记-清除算法 ,现在我们需要明确什么是垃圾什么是被使用中的内存空间。
垃圾,指的是之前分配但现在不再使用的内存。使用中的定义则较为复杂,有以下几点:
所以我们需要从变量开始遍历对象,以达到所有可以到达的对象,对于不可到达的对象,将其收回。
关键在于对对象的遍历和标记,其原理十分的简单:
垃圾回收器常常被用于各种编程语言中,但是我们这只是演示其功能,我们就只用基本的数据类型,和简单的虚拟机来实现这个功能并创建一些可回收的垃圾。我们用一个枚举变量来标识对象的类型:
1 | typedef enum{ |
这里定义了两种数据类型,一个是数据类型,一个是成对类型。这个对可以是各种组合,可以是一对数,或者一对对对象,或者一个数和一个对对象。这样的话可以实现对象之间的相互引用,这样的话就可以得到一个连续的引用对象的树,我们我们可以定义出它:
1 | typedef struct sObject{ |
用type
字段来标识值的类型——int/pair,然后用联合体来存储
现在我们可以在虚拟机种使用这个数据类型,我们的虚拟机拥有一个栈,用来存储当前作用域中的变量。
1 | typedef struct { |
这个结构体中包含两个部分:
我们在此基础之上继续编写一个创建并初始化虚拟机的函数:
1 | VM * newVM(){ |
然后我们添加能对虚拟机的栈进行操作的函数:
1 | void push(VM * vm,Object * value){ |
我们现在可以存放变量了,那么我们也需要一个创建变量的程序:
1 | Object * newObject(VM * vm,ObjectType type){ |
上面的辅助函数实现了对变量内存的分配,和内存类型的设定,我们在此基础上编写指定的数据类型:
1 | void pushInt(VM *vm,int intValue){ |
这就是我们的小虚拟机,如果我们真的有一个调用这些函数的解析器和解释器,那么我们手上就真的有一门语言了。如果我们有足够的内存,它甚至可以运行真正的程序。
我们可以在之前的基础上开始我们的垃圾回收,第一个阶段就是标记。我们需要遍历所有可以到达的对象并设置他们的标记位,现在我们需要为之前的Object
结构设置一个标记位。且当我们创建一个新的对象时,我们修改newObject()
以初始化marked
为零。
1 | typedef struct sObject{ |
接下来我们开始准备标记所有可到达的函数,我们先从内存中的变量开始(即遍历栈),不过首先我们需要实现我们的标记函数,然后再遍历标记中调用:
1 | void mark(Object * object){ |
不过这还远远不够,我们标记的对象本身确实是可以到达的,但是我们还有一种成对类型。在这里,我们需要意识到可达性是传递的,我们可以用递归实现这个过程:
1 | void mark(Object * object){ |
现在我们的标记已经完成了,我们可以使用markAll()
来实现对内存空间的标记
现在最最最重要的一个环节到了,我们需要遍历我们分配的所有对象,并释放那些未被标记到的对象,但是这里有一个问题,按照定义,未标记的对象对我们而言是不可到达的。
VM已经实现了对对象的引用予以,所以哦我们只将对象的指针存储在变量和成对变量中。一旦对象不再被其他变量所引用,虚拟机就完全失去了它,这就发生了内存泄露。为了解决这个问题,VM需要有自己的对象引用,这些引用和用户可见的语义是不同的,换而言之,我们可以自己跟踪它们。
实现的方式也很简单,我们创建一个链表,用来加入我们分配过的所有对象,我们在Object
中完成这个过程:
1 | typedef struct sObject{ |
虚拟机则负责跟踪该列表的头部:
1 | typedef struct { |
在newVM()
中,我们将头指针初始化为NULL。每次创建对象时,我们都将其添加到列表中:
1 | VM * newVM(){ |
现在我们想要删除未标记的对象,只需要遍历列表即可,我们进行清除函数的实现
1 | void sweep(VM * vm){ |
这个代码看起来确实抽象,但是其逻辑实际上十分简单。它遍历整个链表。每当遇到未标记的对象,就释放内存,并将其从链表中移除。执行这个过程中后,我们就删除了所有不可到达的对象。(这里的二重指针真搞人心态,实际上这个object指向当前对象的next地址)。
OK至此为止,我们的垃圾回收器已经完成了,我们将其简单的合并起来:
1 | void GC(VM * vm){ |
但是不止如此,由于内存是有限的,我们需要设置一个对象数量的阈值,一旦超过这个数量就启动GC对内存进行垃圾回收,我们可以通过拓展VM来计算和设置数量:
1 | typedef struct { |
然后再VM的创建中初始化他们:
1 | VM * newVM(){ |
其中INITIAL_GC_THRESHOLD
是第一次启动垃圾回收的对象的数量,这个可以自行调整。
当我们每创建一个对象时,我们都需要增加numObject
的数量并进行判断,当其到达最大值时进行垃圾回收:
1 | Object * newObject(VM * vm,ObjectType type){ |
当然每当我们使用sweep
释放一个对象,我们也应该在程序中减少当前对象的数量:
1 | void sweep(VM * vm){ |
最后我们需要修改GC()
来更新最大值:
1 | void GC(VM * vm){ |
每次回收后我们的剩余活动对象数量会动态更新GC的触发值,如果活动对象变多,就会扩大。如果大量的活动对象被释放,就会自动缩小。
本来是想演示一下这个程序怎么使用的,感觉还是算了,因为这个过程并不是很直观,所以就不在此演示了,不过如果能一直看到这里,想必整个过程始终是心里有数的了,就不在此过多概述。
好吧我还是让AI写了一个测试集:
1 | int main() { |
这是它的测试集,符合我们想要的结果。
1 | #include <stdio.h> |