Shadertoy学习 vol. 2 相机,漫反射,光源

result

写在开头

这篇教程大量引用了blog.demofox.org的内容,但对其中的部分代码做出了修改,以便在vscode的Shader Toy中运行。本文会对部分实现原理做出更加详细的解释,以方便那些对光线追踪和渲染基础没有足够基础的同志。

原博文的链接在此,有需要的可以去参考。

我的代码也可以拿走下载 点击下载

光追的基本原理

graph

光追的基础便是从摄像机处向每个像素发出射线,并检测射线与物体发生的碰撞。当射线碰到光源时,像素便会被照亮。当然细节要比这复杂很多,但你现在已经对原理有了一个基本的认知了,下面就让我们开始一步一步地搭建光追渲染器吧。

创建光线

对于每一个屏幕上的像素,都会有一条光线从摄像机穿过它,再射向屏幕内部。所以我们的第一步工作便是确定每一条光线的起点和方向。

我们把像素所在的平面的上方向和右方向设为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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void main()
{
// The ray starts at the camera position (the origin)
vec3 rayPosition = vec3(0.0f, 0.0f, 0.0f);

// calculate coordinates of the ray target on the imaginary pixel plane.
// -1 to +1 on x,y axis. 1 unit away on the z axis
vec3 rayTarget = vec3((gl_FragCoord.xy/iResolution.xy) * 2.0f - 1.0f,, 1.0f);

// calculate a normalized vector for the ray direction.
// it's pointing from the ray position to the ray target.
vec3 rayDir = normalize(rayTarget - rayPosition);

// show the ray direction
gl_FragColor = vec4(rayDir, 1.0f);
}

渲染几何体

在渲染几何体时,我们需要一个函数,可以在给出几何体位置,形状信息和射线方向位置时返回是否碰撞和碰撞距离。

让我们来定义一个碰撞信息的结构体:

1
2
3
4
5
6
7
struct SRayHitInfo
{
float dist;
vec3 normal;
vec3 albedo;
vec3 emissive;
};

其中albedo和emissive是后面路径追踪时用到的,在这一步还不用刻意理解。

显然,对于不同的几何体,他们的碰撞函数并不相同。在这里,我们会使用TestSphereTrace()TestQuadTrace()。这两个函数会定义几何体的位置和形状大小,再根据射线位置以及方向来判断碰撞。这两个碰撞函数的推导比较复杂,这里只会提供代码,有兴趣的同学可以自行研究原理。

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
float ScalarTriple(vec3 u, vec3 v, vec3 w)
{
return dot(cross(u, v), w);
}


bool TestQuadTrace(in vec3 rayPos, in vec3 rayDir, inout SRayHitInfo info, in vec3 a, in vec3 b, in vec3 c, in vec3 d)
{
// calculate normal and flip vertices order if needed
vec3 normal = normalize(cross(c-a, c-b));
if (dot(normal, rayDir) > 0.0f)
{
normal *= -1.0f;

vec3 temp = d;
d = a;
a = temp;

temp = b;
b = c;
c = temp;
}

vec3 p = rayPos;
vec3 q = rayPos + rayDir;
vec3 pq = q - p;
vec3 pa = a - p;
vec3 pb = b - p;
vec3 pc = c - p;

// determine which triangle to test against by testing against diagonal first
vec3 m = cross(pc, pq);
float v = dot(pa, m);
vec3 intersectPos;
if (v >= 0.0f)
{
// test against triangle a,b,c
float u = -dot(pb, m);
if (u < 0.0f) return false;
float w = ScalarTriple(pq, pb, pa);
if (w < 0.0f) return false;
float denom = 1.0f / (u+v+w);
u*=denom;
v*=denom;
w*=denom;
intersectPos = u*a+v*b+w*c;
}
else
{
vec3 pd = d - p;
float u = dot(pd, m);
if (u < 0.0f) return false;
float w = ScalarTriple(pq, pa, pd);
if (w < 0.0f) return false;
v = -v;
float denom = 1.0f / (u+v+w);
u*=denom;
v*=denom;
w*=denom;
intersectPos = u*a+v*d+w*c;
}

float dist;
if (abs(rayDir.x) > 0.1f)
{
dist = (intersectPos.x - rayPos.x) / rayDir.x;
}
else if (abs(rayDir.y) > 0.1f)
{
dist = (intersectPos.y - rayPos.y) / rayDir.y;
}
else
{
dist = (intersectPos.z - rayPos.z) / rayDir.z;
}

if (dist > c_minimumRayHitTime && dist < info.dist)
{
info.dist = dist;
info.normal = normal;
return true;
}

return false;
}


bool TestSphereTrace(in vec3 rayPos, in vec3 rayDir, inout SRayHitInfo info, in vec4 sphere)
{
//get the vector from the center of this sphere to where the ray begins.
vec3 m = rayPos - sphere.xyz;

//get the dot product of the above vector and the ray's vector
float b = dot(m, rayDir);

float c = dot(m, m) - sphere.w * sphere.w;

//exit if r's origin outside s (c > 0) and r pointing away from s (b > 0)
if(c > 0.0 && b > 0.0)
return false;

//calculate discriminant
float discr = b * b - c;

//a negative discriminant corresponds to ray missing sphere
if(discr < 0.0)
return false;

//ray now found to intersect sphere, compute smallest t value of intersection
bool fromInside = false;
float dist = -b - sqrt(discr);
if (dist < 0.0f)
{
fromInside = true;
dist = -b + sqrt(discr);
}

if (dist > c_minimumRayHitTime && dist < info.dist)
{
info.dist = dist;
info.normal = normalize((rayPos+rayDir*dist) - sphere.xyz) * (fromInside ? -1.0f : 1.0f);
return true;
}

return false;
}

在计算光线是否与物体发生碰撞了之后,我们需要根据结果来改变光线的颜色值。但在这一步我们可以先来验证一下我们是否成功的检测了光线的碰撞,所以先写出一个碰撞后直接返回物体颜色,不进行其他计算的函数。

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
const float c_superFar = 10000.0f;

vec3 GetColorForRay(in vec3 rayPos, in vec3 rayDir)
{
SRayHitInfo hitInfo;
hitInfo.dist = c_superFar;

vec3 ret = vec3(0.0f, 0.0f, 0.0f);

if (TestSphereTrace(rayPos, rayDir, hitInfo, vec4(-10.0f, 0.0f, 20.0f, 1.0f)))
{
ret = vec3(1.0f, 0.1f, 0.1f);
}

if (TestSphereTrace(rayPos, rayDir, hitInfo, vec4(0.0f, 0.0f, 20.0f, 1.0f)))
{
ret = vec3(0.1f, 1.0f, 0.1f);
}

{
vec3 A = vec3(-15.0f, -15.0f, 22.0f);
vec3 B = vec3( 15.0f, -15.0f, 22.0f);
vec3 C = vec3( 15.0f, 15.0f, 22.0f);
vec3 D = vec3(-15.0f, 15.0f, 22.0f);
if (TestQuadTrace(rayPos, rayDir, hitInfo, A, B, C, D))
{
ret = vec3(0.7f, 0.7f, 0.7f);
}
}

if (TestSphereTrace(rayPos, rayDir, hitInfo, vec4(10.0f, 0.0f, 20.0f, 1.0f)))
{
ret = vec3(0.1f, 0.1f, 1.0f);
}

return ret;
}

看一下结果:
Test

这里我的背景是红的是因为我当时不小心把ret的初始值设成vec3 (1.0f, 0.0f, 0.0f)了,你得到的背景应该还是黑色的。

比例矫正

现在出现了一个问题,因为我们的像素所在坐标位置被我们归一化了,所以渲染出来的图形的比例发生了错误,长方形成正方形了。

这里我们在mian()函数里添加两行代码:

1
2
3
// correct for aspect ratio
float aspectRatio = iResolution.x / iResolution.y;
rayTarget.y /= aspectRatio;

先根据iResolution算出屏幕比例,再改变像素的坐标。

FOV

我们的相机距离像素平面为1个单位,经过简单的三角型计算便可以得到我们的视角为90°。如果我们减小相机与像素平面的距离,那FOV便会变大,反之同理。

我们可以修改以下代码来通过计算相机与平面距离来调整相机FOV:

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
const float c_FOVDegrees = 90.0f;

void main()
{
// The ray starts at the camera position (the origin)
vec3 rayPosition = vec3(0.0f, 0.0f, 0.0f);

// calculate the camera distance
float cameraDistance = 1.0f / tan(c_FOVDegrees * 0.5f * c_pi / 180.0f);

// calculate coordinates of the ray target on the imaginary pixel plane.
// -1 to +1 on x,y axis. 1 unit away on the z axis
vec3 rayTarget = vec3((gl_FragCoord.xy/iResolution.xy) * 2.0f - 1.0f, cameraDistance);

// correct for aspect ratio
float aspectRatio = iResolution.x / iResolution.y;
rayTarget.y /= aspectRatio;

// calculate a normalized vector for the ray direction.
// it's pointing from the ray position to the ray target.
vec3 rayDir = normalize(rayTarget - rayPosition);

// raytrace for this pixel
vec3 color = GetColorForRay(rayPosition, rayDir);

// show the result
gl_FragColor = vec4(color, 1.0f);
}

当FOV设置到120的时候,图形大概是这样的:

Test2

最激动人心的一步,光线追踪

1.

之前我们在碰撞信息里定义了两个变量:

1
2
vec3 emissive //物体自发光的颜色
vec3 albedo //在白光下的颜色

对于普通不发光物体,它的emissive会是0向量,albedo里的x, y, z会描述它的RGB颜色;对于光源,它的albedo会是0向量,emissive的x, y, z则描述它的发光颜色。

在这里的光线追踪并不模拟物理世界的法则(虽然我也很想做PBR,不过对我来说太难了),我们会简单地将像素默认颜色设为黑色,以及一个白色的throughout,然后定义以下规则:

  1. 当光线照射到物体上时,emissive * throughout 将添加到像素的颜色上。
  2. 当光线照射到物体上时,throughout 会乘以该物体的 albedo,这会影响接下来的光的颜色。
  3. 当光线照射到物体上时,将在随机方向上反射并继续与场景相交
  4. 当光线错过所有对象,或者到达 N 次反弹时,将终止。(本程序将N设置为了8,若性能需求太高,可自行调整)

举一个例子:当光线击中白球,反弹又击中红球,再次反弹并击中白光,此时像素应是红色。

也就是说,当光线击中有颜色的物体时,接下来的光线都会乘以该物体的颜色。

2.

针对反射,我们这里的反射均为漫反射,在宏观上是不规则的。

Diffuse Reflection

为此,我们需要一个随机数生成器来获得反射后的光线方向。我们使用像素位置和当前帧数作为随机种子,让每个像素在每帧都能获得不同的随机数

1
2
// initialize a random number state based on frag coord and frame
uint rngState = uint(uint(gl_FragCoord.x) * uint(1973) + uint(gl_FragCoord.y) * uint(9277) + uint(iFrame) * uint(26699)) | uint(1);

把它放置在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
uint wang_hash(inout uint seed)
{
seed = uint(seed ^ uint(61)) ^ uint(seed >> uint(16));
seed *= uint(9);
seed = seed ^ (seed >> 4);
seed *= uint(0x27d4eb2d);
seed = seed ^ (seed >> 15);
return seed;
}

float RandomFloat01(inout uint state)
{
return float(wang_hash(state)) / 4294967296.0;
}

vec3 RandomUnitVector(inout uint state)
{
float z = RandomFloat01(state) * 2.0f - 1.0f;
float a = RandomFloat01(state) * c_twopi;
float r = sqrt(1.0f - z * z);
float x = r * cos(a);
float y = r * sin(a);
return vec3(x, y, z);
}

把之前GetColorForRay()的内容放进TestSceneTrace()内。这时,我们可以完成真正的GetColorForRay()了:

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
void TestSceneTrace(in vec3 rayPos, in vec3 rayDir, inout SRayHitInfo hitInfo)
{
{
vec3 A = vec3(-15.0f, -15.0f, 22.0f);
vec3 B = vec3( 15.0f, -15.0f, 22.0f);
vec3 C = vec3( 15.0f, 15.0f, 22.0f);
vec3 D = vec3(-15.0f, 15.0f, 22.0f);
if (TestQuadTrace(rayPos, rayDir, hitInfo, A, B, C, D))
{
hitInfo.albedo = vec3(0.7f, 0.7f, 0.7f);
hitInfo.emissive = vec3(0.0f, 0.0f, 0.0f);
}
}

if (TestSphereTrace(rayPos, rayDir, hitInfo, vec4(-10.0f, 0.0f, 20.0f, 1.0f)))
{
hitInfo.albedo = vec3(1.0f, 0.1f, 0.1f);
hitInfo.emissive = vec3(0.0f, 0.0f, 0.0f);
}

if (TestSphereTrace(rayPos, rayDir, hitInfo, vec4(0.0f, 0.0f, 20.0f, 1.0f)))
{
hitInfo.albedo = vec3(0.1f, 1.0f, 0.1f);
hitInfo.emissive = vec3(0.0f, 0.0f, 0.0f);
}

if (TestSphereTrace(rayPos, rayDir, hitInfo, vec4(10.0f, 0.0f, 20.0f, 1.0f)))
{
hitInfo.albedo = vec3(0.1f, 0.1f, 1.0f);
hitInfo.emissive = vec3(0.0f, 0.0f, 0.0f);
}


if (TestSphereTrace(rayPos, rayDir, hitInfo, vec4(10.0f, 10.0f, 20.0f, 5.0f)))
{
hitInfo.albedo = vec3(0.0f, 0.0f, 0.0f);
hitInfo.emissive = vec3(1.0f, 0.9f, 0.7f) * 100.0f;
}
}


vec3 GetColorForRay(in vec3 startRayPos, in vec3 startRayDir, inout uint rngState)
{
// initialize
vec3 ret = vec3(0.0f, 0.0f, 0.0f);
vec3 throughput = vec3(1.0f, 1.0f, 1.0f);
vec3 rayPos = startRayPos;
vec3 rayDir = startRayDir;

for (int bounceIndex = 0; bounceIndex <= c_numBounces; ++bounceIndex)
{
// shoot a ray out into the world
SRayHitInfo hitInfo;
hitInfo.dist = c_superFar;
TestSceneTrace(rayPos, rayDir, hitInfo);

// if the ray missed, we are done
if (hitInfo.dist == c_superFar)
break;

// update the ray position
rayPos = (rayPos + rayDir * hitInfo.dist) + hitInfo.normal * c_rayPosNormalNudge;

// calculate new ray direction, in a cosine weighted hemisphere oriented at normal
rayDir = normalize(hitInfo.normal + RandomUnitVector(rngState));

// add in emissive lighting
ret += hitInfo.emissive * throughput;

// update the colorMultiplier
throughput *= hitInfo.albedo;
}

// return pixel color
return ret;
}

这里我们给一开始的光线碰撞距离设置了一个极大值,如果反射8次了碰撞距离还未更新,则认为光线未发生碰撞。
这里用cosine weight hemisphere是因为Lambert cosine rule, 能给出更真实的反射效果,追求深刻理解的可以去自行谷歌。

运行一下,看看效果:
Test3

这时的图像只是一堆零散的点是因为每一帧里光线打到的点都不一样,我们需要让像素去显示在所有帧里的平均值。

平均像素值

我们可以添加多个通道来平均像素值。这里新建一个文件,把前一个文件选为iChannel0:

1
2
3
4
5
6
7
#iChannel0 "file://test3.frag"


void main()
{
vec3 color = texture(iChannel0, gl_FragCoord.xy / iResolution.xy).rgb;
gl_FragColor = vec4(color, 1.0f);

同时在上一个文件中添加自己作为一个channel:

1
#iChannel0 "self"

再往main()中添加混合像素值的代码:

1
2
vec3 lastFrameColor = texture(iChannel0, gl_FragCoord.xy / iResolution.xy).rgb;
color = mix(lastFrameColor, color, 1.0f / float(iFrame + 1));

搞定!!!

最后

我们可以再布置一下场景:

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
void TestSceneTrace(in vec3 rayPos, in vec3 rayDir, inout SRayHitInfo hitInfo)
{
// to move the scene around, since we can't move the camera yet
vec3 sceneTranslation = vec3(0.0f, 0.0f, 10.0f);
vec4 sceneTranslation4 = vec4(sceneTranslation, 0.0f);

// back wall
{
vec3 A = vec3(-12.6f, -12.6f, 25.0f) + sceneTranslation;
vec3 B = vec3( 12.6f, -12.6f, 25.0f) + sceneTranslation;
vec3 C = vec3( 12.6f, 12.6f, 25.0f) + sceneTranslation;
vec3 D = vec3(-12.6f, 12.6f, 25.0f) + sceneTranslation;
if (TestQuadTrace(rayPos, rayDir, hitInfo, A, B, C, D))
{
hitInfo.albedo = vec3(0.7f, 0.7f, 0.7f);
hitInfo.emissive = vec3(0.0f, 0.0f, 0.0f);
}
}

// floor
{
vec3 A = vec3(-12.6f, -12.45f, 25.0f) + sceneTranslation;
vec3 B = vec3( 12.6f, -12.45f, 25.0f) + sceneTranslation;
vec3 C = vec3( 12.6f, -12.45f, 15.0f) + sceneTranslation;
vec3 D = vec3(-12.6f, -12.45f, 15.0f) + sceneTranslation;
if (TestQuadTrace(rayPos, rayDir, hitInfo, A, B, C, D))
{
hitInfo.albedo = vec3(0.7f, 0.7f, 0.7f);
hitInfo.emissive = vec3(0.0f, 0.0f, 0.0f);
}
}

// cieling
{
vec3 A = vec3(-12.6f, 12.5f, 25.0f) + sceneTranslation;
vec3 B = vec3( 12.6f, 12.5f, 25.0f) + sceneTranslation;
vec3 C = vec3( 12.6f, 12.5f, 15.0f) + sceneTranslation;
vec3 D = vec3(-12.6f, 12.5f, 15.0f) + sceneTranslation;
if (TestQuadTrace(rayPos, rayDir, hitInfo, A, B, C, D))
{
hitInfo.albedo = vec3(0.7f, 0.7f, 0.7f);
hitInfo.emissive = vec3(0.0f, 0.0f, 0.0f);
}
}

// left wall
{
vec3 A = vec3(-12.5f, -12.6f, 25.0f) + sceneTranslation;
vec3 B = vec3(-12.5f, -12.6f, 15.0f) + sceneTranslation;
vec3 C = vec3(-12.5f, 12.6f, 15.0f) + sceneTranslation;
vec3 D = vec3(-12.5f, 12.6f, 25.0f) + sceneTranslation;
if (TestQuadTrace(rayPos, rayDir, hitInfo, A, B, C, D))
{
hitInfo.albedo = vec3(0.7f, 0.1f, 0.1f);
hitInfo.emissive = vec3(0.0f, 0.0f, 0.0f);
}
}

// right wall
{
vec3 A = vec3( 12.5f, -12.6f, 25.0f) + sceneTranslation;
vec3 B = vec3( 12.5f, -12.6f, 15.0f) + sceneTranslation;
vec3 C = vec3( 12.5f, 12.6f, 15.0f) + sceneTranslation;
vec3 D = vec3( 12.5f, 12.6f, 25.0f) + sceneTranslation;
if (TestQuadTrace(rayPos, rayDir, hitInfo, A, B, C, D))
{
hitInfo.albedo = vec3(0.1f, 0.7f, 0.1f);
hitInfo.emissive = vec3(0.0f, 0.0f, 0.0f);
}
}

// light
{
vec3 A = vec3(-5.0f, 12.4f, 22.5f) + sceneTranslation;
vec3 B = vec3( 5.0f, 12.4f, 22.5f) + sceneTranslation;
vec3 C = vec3( 5.0f, 12.4f, 17.5f) + sceneTranslation;
vec3 D = vec3(-5.0f, 12.4f, 17.5f) + sceneTranslation;
if (TestQuadTrace(rayPos, rayDir, hitInfo, A, B, C, D))
{
hitInfo.albedo = vec3(0.0f, 0.0f, 0.0f);
hitInfo.emissive = vec3(1.0f, 0.9f, 0.7f) * 20.0f;
}
}

if (TestSphereTrace(rayPos, rayDir, hitInfo, vec4(-9.0f, -9.1f, 18.0f, 3.0f)+sceneTranslation4))
{
hitInfo.albedo = vec3(0.9f, 0.9f, 0.75f);
hitInfo.emissive = vec3(0.0f, 0.0f, 0.0f);
}

if (TestSphereTrace(rayPos, rayDir, hitInfo, vec4(0.0f, -9.1f, 23.0f, 3.0f)+sceneTranslation4))
{
hitInfo.albedo = vec3(0.9f, 0.75f, 0.9f);
hitInfo.emissive = vec3(0.0f, 0.0f, 0.0f);
}

if (TestSphereTrace(rayPos, rayDir, hitInfo, vec4(5.5f, -9.1f, 20.0f, 3.0f)+sceneTranslation4))
{
hitInfo.albedo = vec3(0.75f, 0.9f, 0.9f);
hitInfo.emissive = vec3(0.0f, 0.0f, 0.0f);
}
}

定义了5个墙面,3个球体和一个灯光,组成了图形学的Hello World: Cornell box

让我们的渲染结果看起来更nb一点:

Result

这里我的球并没有贴地放置,因为不知道为什么会出现奇怪的反光,虽然肯定跟没有环境光遮蔽无关,不过还得让我再研究研究。

End

下一期的教程也会根据这个框架来做更深入的效果,敬请期待。

Comments