Shadertoy学习 vol. 2 相机,漫反射,光源
写在开头
这篇教程大量引用了blog.demofox.org的内容,但对其中的部分代码做出了修改,以便在vscode的Shader Toy中运行。本文会对部分实现原理做出更加详细的解释,以方便那些对光线追踪和渲染基础没有足够基础的同志。
原博文的链接在此,有需要的可以去参考。
我的代码也可以拿走下载 点击下载
光追的基本原理
光追的基础便是从摄像机处向每个像素发出射线,并检测射线与物体发生的碰撞。当射线碰到光源时,像素便会被照亮。当然细节要比这复杂很多,但你现在已经对原理有了一个基本的认知了,下面就让我们开始一步一步地搭建光追渲染器吧。
创建光线
对于每一个屏幕上的像素,都会有一条光线从摄像机穿过它,再射向屏幕内部。所以我们的第一步工作便是确定每一条光线的起点和方向。
我们把像素所在的平面的上方向和右方向设为x轴正方向和y轴正方向,摄像机到屏幕的垂直方向则是z轴正方向。(左手坐标系)
在使用glsl写shader时,经常会涉及到的一个概念便是归一化(Normalize),比如说在使用iResolution获取屏幕像素坐标时(假设1080p),我们并不希望右下角的坐标为(1920, 1080),而更希望是(1, 1),代表最边角上的像素。把这个大的区间映射到一个统一的区间便是归一化。
对于我们的渲染器,我们会让屏幕的像素坐标处于-1到1这个区间(x轴和y轴都是),所以我们会用(gl_FragCoord/iResolution.xy) * 2.0f - 1
来进行归一化,想象一下函数的位移和缩放。
我们将相机的坐标放在原点,把目标像素所在平面放在z轴上一单位距离远,这样我们就可以写出代码:
1 | void main() |
渲染几何体
在渲染几何体时,我们需要一个函数,可以在给出几何体位置,形状信息和射线方向位置时返回是否碰撞和碰撞距离。
让我们来定义一个碰撞信息的结构体:
1 | struct SRayHitInfo |
其中albedo和emissive是后面路径追踪时用到的,在这一步还不用刻意理解。
显然,对于不同的几何体,他们的碰撞函数并不相同。在这里,我们会使用TestSphereTrace()
和TestQuadTrace()
。这两个函数会定义几何体的位置和形状大小,再根据射线位置以及方向来判断碰撞。这两个碰撞函数的推导比较复杂,这里只会提供代码,有兴趣的同学可以自行研究原理。
1 | float ScalarTriple(vec3 u, vec3 v, vec3 w) |
在计算光线是否与物体发生碰撞了之后,我们需要根据结果来改变光线的颜色值。但在这一步我们可以先来验证一下我们是否成功的检测了光线的碰撞,所以先写出一个碰撞后直接返回物体颜色,不进行其他计算的函数。
1 | const float c_superFar = 10000.0f; |
看一下结果:
这里我的背景是红的是因为我当时不小心把ret
的初始值设成vec3 (1.0f, 0.0f, 0.0f)
了,你得到的背景应该还是黑色的。
比例矫正
现在出现了一个问题,因为我们的像素所在坐标位置被我们归一化了,所以渲染出来的图形的比例发生了错误,长方形成正方形了。
这里我们在mian()
函数里添加两行代码:
1 | // correct for aspect ratio |
先根据iResolution算出屏幕比例,再改变像素的坐标。
FOV
我们的相机距离像素平面为1个单位,经过简单的三角型计算便可以得到我们的视角为90°。如果我们减小相机与像素平面的距离,那FOV便会变大,反之同理。
我们可以修改以下代码来通过计算相机与平面距离来调整相机FOV:
1 | const float c_FOVDegrees = 90.0f; |
当FOV设置到120的时候,图形大概是这样的:
最激动人心的一步,光线追踪
1.
之前我们在碰撞信息里定义了两个变量:
1 | vec3 emissive //物体自发光的颜色 |
对于普通不发光物体,它的emissive会是0向量,albedo里的x, y, z会描述它的RGB颜色;对于光源,它的albedo会是0向量,emissive的x, y, z则描述它的发光颜色。
在这里的光线追踪并不模拟物理世界的法则(虽然我也很想做PBR,不过对我来说太难了),我们会简单地将像素默认颜色设为黑色,以及一个白色的throughout,然后定义以下规则:
- 当光线照射到物体上时,emissive * throughout 将添加到像素的颜色上。
- 当光线照射到物体上时,throughout 会乘以该物体的 albedo,这会影响接下来的光的颜色。
- 当光线照射到物体上时,将在随机方向上反射并继续与场景相交
- 当光线错过所有对象,或者到达 N 次反弹时,将终止。(本程序将N设置为了8,若性能需求太高,可自行调整)
举一个例子:当光线击中白球,反弹又击中红球,再次反弹并击中白光,此时像素应是红色。
也就是说,当光线击中有颜色的物体时,接下来的光线都会乘以该物体的颜色。
2.
针对反射,我们这里的反射均为漫反射,在宏观上是不规则的。
为此,我们需要一个随机数生成器来获得反射后的光线方向。我们使用像素位置和当前帧数作为随机种子,让每个像素在每帧都能获得不同的随机数
1 | // initialize a random number state based on frag coord and frame |
把它放置在main()
函数内部,再添加其他功能
1 | uint wang_hash(inout uint seed) |
把之前GetColorForRay()
的内容放进TestSceneTrace()
内。这时,我们可以完成真正的GetColorForRay()
了:
1 | void TestSceneTrace(in vec3 rayPos, in vec3 rayDir, inout SRayHitInfo hitInfo) |
这里我们给一开始的光线碰撞距离设置了一个极大值,如果反射8次了碰撞距离还未更新,则认为光线未发生碰撞。
这里用cosine weight hemisphere是因为Lambert cosine rule, 能给出更真实的反射效果,追求深刻理解的可以去自行谷歌。
运行一下,看看效果:
这时的图像只是一堆零散的点是因为每一帧里光线打到的点都不一样,我们需要让像素去显示在所有帧里的平均值。
平均像素值
我们可以添加多个通道来平均像素值。这里新建一个文件,把前一个文件选为iChannel0:
1 |
|
同时在上一个文件中添加自己作为一个channel:
1 |
再往main()
中添加混合像素值的代码:
1 | vec3 lastFrameColor = texture(iChannel0, gl_FragCoord.xy / iResolution.xy).rgb; |
最后
我们可以再布置一下场景:
1 | void TestSceneTrace(in vec3 rayPos, in vec3 rayDir, inout SRayHitInfo hitInfo) |
定义了5个墙面,3个球体和一个灯光,组成了图形学的Hello World: Cornell box
让我们的渲染结果看起来更nb一点:
这里我的球并没有贴地放置,因为不知道为什么会出现奇怪的反光,虽然肯定跟没有环境光遮蔽无关,不过还得让我再研究研究。
End
下一期的教程也会根据这个框架来做更深入的效果,敬请期待。