Shader从入门到放弃(六)—— 在ShaderToy中搭建3D场景

发布于:2024-04-29 ⋅ 阅读:(24) ⋅ 点赞:(0)

前言

今天我们要分享的内容是如何在ShaderToy环境中搭建一个3D场景。

通常,我们构建一个3D场景的常见工作流是:

原画师绘制场景图(将3D场景画下来) -> 建模师根据原画构建场景模型(产出3D模型)-> 程序渲染(将3D模型导入到引擎中,或者其他3D渲染库)

还记得我们在之前WebGL核心原理中阐述的内容吗?如何将一个3D空间的点渲染到我们屏幕上呢?(屏幕本身是一个2D平面)。

img

我们简要回顾一下:

  1. 顶点变换的过程(顶点着色器)
  2. 图元装配(按哪种方式装配图元,装配为点,线还是三角形)
  3. 光栅化(将上述的图元从矢量图形转换为像素组成的图形)
  4. 着色(片段着色器)
  5. 测试(Alpha测试,深度测试,模板测试等)& 混合

通过上述方式渲染的3D模型的方式我们通常称为光栅化的方法

搭建场景

而在我们的shadertoy中,我们是直接在片段着色器中进行编程,我们只有上述的第四与第五步的过程。其实相当于我们现在变成了原画师,我们需要在一张纸上做画,而且画的是3D场景。

好在我们还有另一种构建3D场景的方式,就是光线追踪或光线步进。

image-20240426172359942

如上图所示,想象我们的画布是上图中的红色网格,每个小格子就是我们的一个像素,我们在屏幕的外面进行观察,我们的视线“穿过”每个像素点,与屏幕后方的物体发生了相交。

上述的过程可以用下面的代码进行体现:

 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); // 将球着色为白色
 }

此时的画面看起来就像是有一个二维的圆在屏幕中间似的,但是这实际上是一个三维的小球,如果我们让其运动起来,你就能发生这个事实。

20240426181926_rec_

通过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个球体。此时我们的画面如下。

image-20240427172744129

此时,我们已经学会了如何在shadertoy中创建3D物体,并且采用了射线与物体相交的方式判断射线是否与物体相交 。只不过这里的“物体”,我们通常都是采用某些数学表达式来表示,因为我们难以往shadertoy中传入模型数据。

设置相机

我们现在虽然将场景构建完毕,但是我们却不能移动摄像机,现在我们要解决相机的问题!

我们最容易想到的一个方法是,我们直接修改我们源点ro的位置不就行了吗?事实上是不行的!!!

想象一下,我们的画布是相机的镜头,源点ro则是相机的传感器。如果我们要移动相机,是需要连同“镜头”一起移动,而不能仅仅是移动相机的“传感器”!!!

image-20240427174145757

想象我们的uv坐标其实是在相机空间下的坐标,我们现在需要将其转换到世界坐标中,我们如何转换呢?假设我们的源点ro是世界坐标,我们看向一个target的目标位置,target也是世界坐标。我们需要求解的是“镜头”上面每个像素的世界坐标了。

思路如下:

  1. 先求解相机的坐标系(XYZ)在世界空间下的坐标向量
  2. 再利用公式 ro + forward * zoom + right * uv.x + up * uv.y求解世界空间下的坐标,其中zoom表示相机的缩放大小。

接下来我们讲解一下如何求解相机坐标系在世界空间下的向量。

求解相机坐标系的向量

image-20240427175554494

首先,我们已知的是相机位置ro与观察目标位置target的世界坐标。我们可以轻易的算出rotarget之间的向量,该向量作为相机的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);
 }

image-20240427182224886

给相机的位置加上一点时间值,让其动起来更加明显!

利用矩阵设置相机

至此,你已经学会了如何在shadertoy中设置相机了,但是你可能会许多其他代码中发现另一种设置相机的方法,就是利用矩阵!

[ R U F ] = [ R . x U . x F . x R . y U . y F . y R . z U . z F . z ] \begin{bmatrix} \vec R & \vec U & \vec F \end{bmatrix} = \begin{bmatrix} R.x & U.x & F.x \\ R.y & U.y & F.y \\ R.z & U.z & F.z \\ \end{bmatrix}

我们将上面的矩阵与坐标点相乘得到下面的结果:

[ R . x U . x F . x R . y U . y F . y R . z U . z F . z ] [ x y z ] = [ R . x x U . x y + F . x z R . y x U . y y + F . y z R . z x U . z y + F . z z ] \begin{bmatrix} R.x & U.x & F.x \\ R.y & U.y & F.y \\ R.z & U.z & F.z \\ \end{bmatrix} \begin{bmatrix} x \\ y \\ z \end{bmatrix} = \begin{bmatrix} R.x*x & U.x * y + F.x * z \\ R.y*x & U.y * y + F.y * z \\ R.z*x & U.z * y + F.z * z \end{bmatrix}

在上面我们计算出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);
     }

image-20240427185105891

总结

今天我们学习了如何在shadertoy中利用射线与球相交的方式渲染了球并构建了8个球组成的3D场景,并且学习了如何在shadertoy中设置相机的位置。我们学习了2中不同的方式,关于这一点还需要读者自行多加思考,在后续的编码中,我们通常采用第二种获得相机矩阵并让相机矩阵与点相乘的方式。理解相机的设置非常关键!还望读者多加思考与理解。

完整的代码及效果如下: