前言
今天我们要分享的内容是如何在ShaderToy环境中搭建一个3D场景。
通常,我们构建一个3D场景的常见工作流是:
原画师绘制场景图(将3D场景画下来) -> 建模师根据原画构建场景模型(产出3D模型)-> 程序渲染(将3D模型导入到引擎中,或者其他3D渲染库)
还记得我们在之前WebGL核心原理中阐述的内容吗?如何将一个3D空间的点渲染到我们屏幕上呢?(屏幕本身是一个2D平面)。
我们简要回顾一下:
- 顶点变换的过程(顶点着色器)
- 图元装配(按哪种方式装配图元,装配为点,线还是三角形)
- 光栅化(将上述的图元从矢量图形转换为像素组成的图形)
- 着色(片段着色器)
- 测试(Alpha测试,深度测试,模板测试等)& 混合
通过上述方式渲染的3D模型的方式我们通常称为光栅化的方法
搭建场景
而在我们的shadertoy中,我们是直接在片段着色器中进行编程,我们只有上述的第四与第五步的过程。其实相当于我们现在变成了原画师,我们需要在一张纸上做画,而且画的是3D场景。
好在我们还有另一种构建3D场景的方式,就是光线追踪或光线步进。
如上图所示,想象我们的画布是上图中的红色网格,每个小格子就是我们的一个像素,我们在屏幕的外面进行观察,我们的视线“穿过”每个像素点,与屏幕后方的物体发生了相交。
上述的过程可以用下面的代码进行体现:
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord.xy / iResolution.xy;
uv -= 0.5;
uv.x *= iResolution.x / iResolution.y;
vec3 ro = vec3(0., 0., -1.);
vec3 rd = vec3(uv.x, uv.y, 0.0) - ro;
}
到目前为止的代码中,我构建了一个源点ro
,也就是上面所说的“观察点”。假设我们的画布平面的z坐标是0,所以每个像素点的3D空间中的坐标是 vec3(uv.x, uv.y, 0.0)
但是我们要如何判断我们的射线是否击中了一个物体呢?这与物体的表示方式有关,在shadertoy中,我们通常都是用数学式子来表示一个物体的位置以及大小。例如,我们想要表示一个球,我们需要知道球在空间中的位置已经球的半径或直径。
假设球的位置在点 p 处,球的半径为 r
。我们可以计算这个点与我们从观察点发出的视线之间的距离,如果该距离小于球的半径,则将其着色。
我们根据此理论可以写出下面的代码:
float DistLine(vec3 ro, vec3 rd, vec3 p) {
return length(cross(p - ro, rd) / length(rd));
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord.xy / iResolution.xy;
uv -= 0.5;
uv.x *= iResolution.x / iResolution.y;
vec3 ro = vec3(0., 0., -1.);
vec3 rd = vec3(uv.x, uv.y, 0.0) - ro;
float t = 0.0; //定义一个变量,方便让球动起来
vec3 p = vec3(sin(t), 0., 1. + cos(t)); // 定义球的位置
float r = 0.09; // 定义球的半径
float d = DistLine(ro, rd, p); // 计算球心与观察射线之间的距离
d = smoothstep(r + 0.01, r, d); // 若距离小于半径,d 则等于1。
fragColor = vec4(d, d, d, 1.0); // 将球着色为白色
}
此时的画面看起来就像是有一个二维的圆在屏幕中间似的,但是这实际上是一个三维的小球,如果我们让其运动起来,你就能发生这个事实。
通过GIF图我们可以看出来球的大小是在不断变化的,因为我们的球是在绕着y轴不断地旋转,它一会儿离我们的观察点近,一会离我们的观察点远,符合典型的三维场景中远小近大的特征。
接下来,我们可以多构建几个球。我们创建8个球,将其作为立方体的顶点,这样比较方便我们观察。
float DistLine(vec3 ro, vec3 rd, vec3 p) {
return length(cross(p - ro, rd) / length(rd));
}
float DrawPoint(vec3 ro, vec3 rd, vec3 p) {
float d = DistLine(ro, rd, p);
d = smoothstep(0.06, 0.05, d);
return d;
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord.xy / iResolution.xy;
uv -= 0.5;
uv.x *= iResolution.x / iResolution.y;
vec3 ro = vec3(0., 0., -3.);
vec3 rd = vec3(uv.x, uv.y, -2.0) - ro;
float t = iGlobalTime;
vec3 p = vec3(sin(t), 0., 1. + cos(t));
float d = 0.0;
d += DrawPoint(ro, rd, vec3(0.0, 0.0, 0.0));
d += DrawPoint(ro, rd, vec3(0.0, 0.0, 1.0));
d += DrawPoint(ro, rd, vec3(0.0, 1.0, 0.0));
d += DrawPoint(ro, rd, vec3(0.0, 1.0, 1.0));
d += DrawPoint(ro, rd, vec3(1.0, 0.0, 0.0));
d += DrawPoint(ro, rd, vec3(1.0, 0.0, 1.0));
d += DrawPoint(ro, rd, vec3(1.0, 1.0, 0.0));
d += DrawPoint(ro, rd, vec3(1.0, 1.0, 1.0));
fragColor = vec4(d, d, d, 1.0);
}
在上面的代码中,我们将求解球体与射线之间的距离的函数抽象了出来,命名为 DrawPoint
,并且在mainImage
函数中创建了8个球体。此时我们的画面如下。
此时,我们已经学会了如何在shadertoy中创建3D物体,并且采用了射线与物体相交的方式判断射线是否与物体相交 。只不过这里的“物体”,我们通常都是采用某些数学表达式来表示,因为我们难以往shadertoy中传入模型数据。
设置相机
我们现在虽然将场景构建完毕,但是我们却不能移动摄像机,现在我们要解决相机的问题!
我们最容易想到的一个方法是,我们直接修改我们源点ro
的位置不就行了吗?事实上是不行的!!!
想象一下,我们的画布是相机的镜头,源点ro
则是相机的传感器。如果我们要移动相机,是需要连同“镜头”一起移动,而不能仅仅是移动相机的“传感器”!!!
想象我们的uv坐标其实是在相机空间下的坐标,我们现在需要将其转换到世界坐标中,我们如何转换呢?假设我们的源点ro
是世界坐标,我们看向一个target
的目标位置,target
也是世界坐标。我们需要求解的是“镜头”上面每个像素的世界坐标了。
思路如下:
- 先求解相机的坐标系(XYZ)在世界空间下的坐标向量
- 再利用公式
ro + forward * zoom + right * uv.x + up * uv.y
求解世界空间下的坐标,其中zoom表示相机的缩放大小。
接下来我们讲解一下如何求解相机坐标系在世界空间下的向量。
求解相机坐标系的向量
首先,我们已知的是相机位置ro
与观察目标位置target
的世界坐标。我们可以轻易的算出ro
到target
之间的向量,该向量作为相机的z
方向。对于相机的坐标系XYZ轴,我们通常称为f(forward)、r(right)、u(up)方向,
f = normalize(target - ro)
接下来,我们需要假定一个上方向
,因为我们需要先假设一个上方向,方便我们求解出相机的X轴的向量,最后再通过X轴的向量与Z轴向量求解出真正的Y轴方向。
假设我们的上方向为(0, 1, 0)
,则我们可以求解相机的X轴方向为:
w = vec3(0., 1., 0.);
u = cross(w, z);
注意:由于我们的使用的是左手坐标系,所以我们是使用 w
叉乘 z
,而不是反过来进行叉乘,进行叉乘运算时一定要注意其方向性!
同理,最后我们可以算出相机真正的Y轴方向为u = cross(z, x)
。
现在我们已经求解了相机坐标系的XYZ轴在世界空间中的向量了。接下来我们可以利用公式`forward * zoom + right * uv.x + up * uv.y
计算相机空间下的点在世界空间的位置了,所以镜头上的点可以表示为:
vec3 i = ro + f * zoom + uv.x * r + uv.y * u;
根据上述理论,我们修正后的代码及效果如下:
float DistLine(vec3 ro, vec3 rd, vec3 p) {
return length(cross(p - ro, rd) / length(rd));
}
float DrawPoint(vec3 ro, vec3 rd, vec3 p) {
float d = DistLine(ro, rd, p);
d = smoothstep(0.06, 0.05, d);
return d;
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord.xy / iResolution.xy;
uv -= 0.5;
uv.x *= iResolution.x / iResolution.y;
float t = iGlobalTime;
vec3 ro = vec3(0.0, 0.5, -3.);
vec3 target = vec3(0.5, 0.5, 0.5);
vec3 w = vec3(0., 1., 0.);
vec3 f = normalize(target - ro);
vec3 r = cross(w, f);
vec3 u = cross(f, r);
float zoom = 1.;
vec3 i = ro + f * zoom + uv.x * r + uv.y * u;
vec3 rd = i - ro;
vec3 p = vec3(sin(t), 0., 1. + cos(t));
float d = 0.0;
d += DrawPoint(ro, rd, vec3(0.0, 0.0, 0.0));
d += DrawPoint(ro, rd, vec3(0.0, 0.0, 1.0));
d += DrawPoint(ro, rd, vec3(0.0, 1.0, 0.0));
d += DrawPoint(ro, rd, vec3(0.0, 1.0, 1.0));
d += DrawPoint(ro, rd, vec3(1.0, 0.0, 0.0));
d += DrawPoint(ro, rd, vec3(1.0, 0.0, 1.0));
d += DrawPoint(ro, rd, vec3(1.0, 1.0, 0.0));
d += DrawPoint(ro, rd, vec3(1.0, 1.0, 1.0));
fragColor = vec4(d, d, d, 1.0);
}
给相机的位置加上一点时间值,让其动起来更加明显!
利用矩阵设置相机
至此,你已经学会了如何在shadertoy中设置相机了,但是你可能会许多其他代码中发现另一种设置相机的方法,就是利用矩阵!
我们将上面的矩阵与坐标点相乘得到下面的结果:
在上面我们计算出rd的公式为:
vec3 i = ro + f * zoom + uv.x * r + uv.y * u;
vec3 rd = i - ro;
故 rd = f * zoom + uv.x *r + uv.y * u
我们将rd = f * zoom + uv.x *r + uv.y * u
与上面矩阵相乘的结果进行比较。其中uv.x等价于矩阵中的x
,uv.y等价于矩阵中的y
,zoom则等价于矩阵中的z
。我们发现上述的计算方式跟使用矩阵的方式是一样的!
所以我们可以写出下面的式子:
mat3 getCameraMat(vec3 ro, vec3 ta, vec3 up) {
vec3 f = normalize(ta - ro);
vec3 r = cross(up, f);
vec3 u = normalize(cross(f, r));
return mat3(r, u, f);
}
我们可以使用这个函数来替换上面的代码,我们可以发现他们的效果是等价的。
float DistLine(vec3 ro, vec3 rd, vec3 p) {
return length(cross(p - ro, rd) / length(rd));
}
float DrawPoint(vec3 ro, vec3 rd, vec3 p) {
float d = DistLine(ro, rd, p);
d = smoothstep(0.06, 0.05, d);
return d;
}
mat3 getCameraMat(vec3 ro, vec3 ta, vec3 up) {
vec3 f = normalize(ta - ro);
vec3 r = cross(up, f);
vec3 u = normalize(cross(f, r));
return mat3(r, u, f);
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord.xy / iResolution.xy;
uv -= 0.5;
uv.x *= iResolution.x / iResolution.y;
float t = iGlobalTime;
vec3 ro = vec3(0.0, 0.5, -3.);
vec3 target = vec3(0.5, 0.5, 0.5);
vec3 w = vec3(0., 1., 0.);
vec3 f = normalize(target - ro);
vec3 r = cross(w, f);
vec3 u = cross(f, r);
float zoom = 1.;
vec3 i = ro + f * zoom + uv.x * r + uv.y * u;
vec3 rd = getCameraMat(ro, target, vec3(0., 1., 0.)) * vec3(uv, 1.);
vec3 p = vec3(sin(t), 0., 1. + cos(t));
float d = 0.0;
d += DrawPoint(ro, rd, vec3(0.0, 0.0, 0.0));
d += DrawPoint(ro, rd, vec3(0.0, 0.0, 1.0));
d += DrawPoint(ro, rd, vec3(0.0, 1.0, 0.0));
d += DrawPoint(ro, rd, vec3(0.0, 1.0, 1.0));
d += DrawPoint(ro, rd, vec3(1.0, 0.0, 0.0));
d += DrawPoint(ro, rd, vec3(1.0, 0.0, 1.0));
d += DrawPoint(ro, rd, vec3(1.0, 1.0, 0.0));
d += DrawPoint(ro, rd, vec3(1.0, 1.0, 1.0));
fragColor = vec4(d, d, d, 1.0);
}
总结
今天我们学习了如何在shadertoy中利用射线与球相交的方式渲染了球并构建了8个球组成的3D场景,并且学习了如何在shadertoy中设置相机的位置。我们学习了2中不同的方式,关于这一点还需要读者自行多加思考,在后续的编码中,我们通常采用第二种获得相机矩阵并让相机矩阵与点相乘的方式。理解相机的设置非常关键!还望读者多加思考与理解。
完整的代码及效果如下: