球体的SDF模型与打光

666 阅读4分钟

源码:github.com/buglas/shad…

在绘制三维的SDF模型前,我们需要先知道RayMarching算法,因为它可以将SDF模型显示出来。

1-RayMarching

RayMarching 译作光线步进,或者射线推进,其意思就是让射线的起点沿着射线方向逐步推进,每次推进的距离就是射线起点到SDF模型的距离。

image-20220707102732861

利用RayMarching 显示模型的步骤:

  1. 将相机视点作为射线的起点。
  2. 在相机前面放一张栅格图像。
  3. 以相机视点为起点,向栅格图像的每个栅格做射线。
  4. 让相机视点沿射线方向进行逐步推进,每次推进的距离都是相机视点到SDF模型表面的距离。

2-用RayMarching 算法绘制球体

我们先用RayMarching 算法把球体显示出来看看。

image-20220904171555566

整体代码如下:

// 坐标系缩放
#define PROJECTION_SCALE  1.

// 球体的球心位置
#define SPHERE_POS vec3(0, 0, -2)
// 球体的半径
#define SPHERE_R 1.0
// 球体的漫反射系数
#define SPHERE_KD vec3(1)

// 相机视点位
#define CAMERA_POS vec3(0, 0, 2)

// 光线推进的起始距离 
#define RAYMARCH_NEAR 0.1
// 光线推进的最远距离
#define RAYMARCH_FAR 128.
// 光线推进次数
#define RAYMARCH_TIME 20
// 当推进后的点位距离物体表面小于RAYMARCH_PRECISION时,默认此点为物体表面的点
#define RAYMARCH_PRECISION 0.001

// 投影坐标系
vec2 ProjectionCoord(in vec2 coord) {
  return PROJECTION_SCALE * 2. * (coord - 0.5 * iResolution.xy) / min(iResolution.x, iResolution.y);
}

//从相机视点到片元的射线
vec3 RayDir(in vec2 coord) {
  return normalize(vec3(coord, 0) - CAMERA_POS);
}

//球体的SDF模型
float SDFSphere(vec3 coord) {
  return length(coord - SPHERE_POS) - SPHERE_R;
}

// 光线推进
vec3 RayMarch(vec2 coord) {
  float d = RAYMARCH_NEAR;
  // 从相机视点到当前片元的射线
  vec3 rd = RayDir(coord);
  // 片元颜色
  vec3 color = vec3(0);
  for(int i = 0; i < RAYMARCH_TIME && d < RAYMARCH_FAR; i++) {
    // 光线推进后的点位
    vec3 p = CAMERA_POS + d * rd;
    // 光线推进后的点位到球体的有向距离
    float curD = SDFSphere(p);
    // 若有向距离小于一定的精度,默认此点在球体表面
    if(curD < RAYMARCH_PRECISION) {
      color = SPHERE_KD;
      break;
    }
    // 距离累加
    d += curD;
  }
  return color;
}

/* 绘图函数,画布中的每个片元都会执行一次,执行方式是并行的。
fragColor 输出参数,用于定义当前片元的颜色。
fragCoord 输入参数,当前片元的位置,原点在画布左下角,右侧边界为画布的像素宽,顶部边界为画布的像素高
*/
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
  // 当前片元的栅格图像位
  vec2 coord = ProjectionCoord(fragCoord.xy);
  // 光线推进
  vec3 color = RayMarch(coord);
  // 最终颜色
  fragColor = vec4(color, 1);
}

在上面的代码里,我都有详细的注释,所以我只概括一下这个过程。

1.定义球体相关的数据。

// 球体的球心位置
#define SPHERE_POS vec3(0, 0, -2)
// 球体的半径
#define SPHERE_R 1.0
// 球体的漫反射系数
#define SPHERE_KD vec3(1)

2.定义相机视点位。

#define CAMERA_POS vec3(0, 0, 2)

这是一个透视相机,关于透视相机的概念大家可以点击此链接了解。

相机视点到栅格图像的距离会影响视锥体的夹角。

从下面的代码中可以看到,当前栅格图像的z位置是0。

//从相机视点到片元的射线
vec3 RayDir(in vec2 coord) {
  return normalize(vec3(coord, 0) - CAMERA_POS);
}

3.定义光线推进相关的数据。

// 光线推进的起始距离 
#define RAYMARCH_NEAR 0.1
// 光线推进的最远距离
#define RAYMARCH_FAR 128.
// 光线推进次数
#define RAYMARCH_TIME 20
// 当推进后的点位距离物体表面小于RAYMARCH_PRECISION时,默认此点为物体表面的点
#define RAYMARCH_PRECISION 0.001

光线推进的起始距离和最远距离有点类似于下相机的近裁剪距离和远裁剪距离,只不过光线推进的距离会自视点放射构成一个球面,而相机的裁剪距离是视点到平面的距离。

光线推进次数会非常影响渲染速度,所以大家在定义的时候需谨慎,不要太大。

4.建立球体的SDF 模型。

float SDFSphere(vec3 coord) {
  return length(coord - SPHERE_POS) - SPHERE_R;
}

SDFSphere() 方法返回的就是空间点位到球体表面的有向距离。

5.以相机视点为起点,向栅格图像的每个栅格做射线,然后以此为方向推进射线,判断此射线是否会穿过球体。若穿过了球体,那就为当前片元着上球体的颜色。

光线推进的初始距离就是相机的近裁剪距离CAMERA_NEAR,当空间点位到球体表面的有向距离小于近裁剪距离,或者大于远裁剪距离CAMERA_FAR时,便不会再进行光线推进,这样相应位置的物体便会被裁剪。

vec3 RayMarch(vec2 coord) {
  float d = RAYMARCH_NEAR;
  // 从相机视点到当前片元的射线
  vec3 rd = RayDir(coord);
  // 片元颜色
  vec3 color = vec3(0);
  for(int i = 0; i < RAYMARCH_TIME && d < RAYMARCH_FAR; i++) {
    // 光线推进后的点位
    vec3 p = CAMERA_POS + d * rd;
    // 光线推进后的点位到球体的有向距离
    float curD = SDFSphere(p);
    // 若有向距离小于一定的精度,默认此点在球体表面
    if(curD < RAYMARCH_PRECISION) {
      color = SPHERE_KD;
      break;
    }
    // 距离累加
    d += curD;
  }
  return color;
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
  // 当前片元的栅格图像位
  vec2 coord = ProjectionCoord(fragCoord.xy);
  // 光线推进
  vec3 color = RayMarch(coord);
  // 最终颜色
  fragColor = vec4(color, 1);
}

现在我们已经完成了对三维SDF 模型的显示,接下来我们可以在这上面打光,使其具备真正的三维立体感。

3-球体打光

我们根据SDF模型的特性,可以很容易获取其法线。

有了法线之后,我们便可以基于法线和光线的夹角、法线和视线的夹角等,设置其光照效果和材质效果。

咱们先说一下如何计算SDF模型的法线。

3-1-SDF模型的法线

要确定SDF模型表面的任意点的法线,可以通过此点周围的4个点来确定。

image-20220906235939330

已知:O点是球体的SDF模型表面上的任意点。

求:SDF模型在O点处的法线

解:

1.以O点为基点定义4个点位:

  • K0(1,-1,-1)
  • K1(-1,-1,1)
  • K2(-1,1,-1)
  • K3(1,1,1)

2.取一个极小值h 作为向量长度,比如h=0.0001

3.以h为长度,在OK0,OK1,OK2,OK3 四个方向上分别取A0,A1,A2,A3 四个点

4.在SDF模型中获取A0,A1,A2,A3 处的有向距离a0,a1,a2,a3,根据图像可知:

  • a0,a1小于0
  • a2,a3大于0

5.分别以a0,a1,a2,a3为长度,以OK0,OK1,OK2,OK3为方向取四个点B0,B1,B3,B4。

image-20220907103659273

在当前的图像中,因为a0,a1小于0,所以OB0,OB1的方向会与OK0,OK1的方向相反。

6.将向量OB0,OB1,OB3,OB4加在一起,然后归一化,便是SDF模型在O点处的法线。

由上图可知向量OB0,OB1,OB3,OB4的和,会是一个方向接近y轴向量。

至于为什么这么做就可以得到法线,这跟导数有关,我们在此就不再过多解释。

我们当前的这种计算方法是有误差的。

h越小,误差越小,但是h又不能太小,因为浮点数是有精度限制的,所以h值一般给0.0001就可以了。

其代码实现如下:

//球体的SDF模型
float SDFSphere(vec3 coord) {
  return length(coord - SPHERE_POS) - SPHERE_R;
}

// 计算球体的法线
vec3 SDFNormal(in vec3 p) {
  const float h = 0.0001;
  const vec2 k = vec2(1, -1);
  return normalize(k.xyy * SDFSphere(p + k.xyy * h) +
    k.yyx * SDFSphere(p + k.yyx * h) +
    k.yxy * SDFSphere(p + k.yxy * h) +
    k.xxx * SDFSphere(p + k.xxx * h));
}

附上IQ大神的相关博客:iquilezles.org/articles/no…

现在法线已经有了,接下来我们利用法线打一个点光。

3-2-点光源

光线和着色点法线的点积可以作为判断着色点受光强度的标准。

解释一下这个原理。

在同样的光源下,入射光线和着色点法线的夹角会影响着色点接收光线的数量。

image-20211026183025315

在上图中,假如第一个着色点能接收6条光线,则当着色点旋转45°后,它就只能接收4条光线了。

因此,我们会用入射光线l 和着色点法线n的夹角的余弦值,来表示着色点的受光程度。

cosθ=l·n

解释一下上面的等式是怎么来的。

由点积公式得:

n=cosθ*|l|*|n|

所以:

cosθ=l·n/|l|*|n|

因为:l,n为单位向量

所以:

cosθ=l·n/1*1
cosθ=l·n

其代码实现如下:

// 点光源位置
#define LIGHTPOS vec3(cos(iTime), 1, 0)
// 打光
vec3 AddLight(vec3 positon) {
  // 当前着色点的法线
  vec3 n = SDFNormal(positon);
  // 当前着色点到光源的方向
  vec3 lightDir = normalize(LIGHTPOS - positon);
  // 漫反射
  vec3 diffuse = SPHERE_KD * max(dot(lightDir, n), 0.);
  // 环境光
  float amb = 0.15 + dot(-lightDir, n) * 0.2;
  // 最终颜色
  return diffuse + amb;
}

在上面的代码里,我还建立了一个环境光,这个环境光会在点光源的对面给SDF模型一个补光效果:

image-20220907153308034

现在,为球体打光的原理就已经说完了,其整体代码如下:

// 坐标系缩放
#define PROJECTION_SCALE  1.

// 球体的球心位置
#define SPHERE_POS vec3(0, 0, -2)
// 球体的半径
#define SPHERE_R 1.0
// 球体的漫反射系数
#define SPHERE_KD vec3(1)

// 相机视点位
#define CAMERA_POS vec3(0, 0, 2)

// 光线推进的起始距离 
#define RAYMARCH_NEAR 0.1
// 光线推进的最远距离
#define RAYMARCH_FAR 128.
// 光线推进次数
#define RAYMARCH_TIME 20
// 当推进后的点位距离物体表面小于RAYMARCH_PRECISION时,默认此点为物体表面的点
#define RAYMARCH_PRECISION 0.001

// 点光源位置
#define LIGHT_POS vec3(cos(iTime), 1, 0)

// 投影坐标系
vec2 ProjectionCoord(in vec2 coord) {
  return PROJECTION_SCALE * 2. * (coord - 0.5 * iResolution.xy) / min(iResolution.x, iResolution.y);
}

//从相机视点到片元的射线
vec3 RayDir(in vec2 coord) {
  return normalize(vec3(coord, 0) - CAMERA_POS);
}

//球体的SDF模型
float sdfSphere(vec3 coord) {
  return length(coord - SPHERE_POS) - SPHERE_R;
}

// 计算球体的法线
vec3 SDFSphere(in vec3 p) {
  const float h = 0.0001;
  const vec2 k = vec2(1, -1);
  return normalize(k.xyy * sdfSphere(p + k.xyy * h) +
    k.yyx * sdfSphere(p + k.yyx * h) +
    k.yxy * sdfSphere(p + k.yxy * h) +
    k.xxx * sdfSphere(p + k.xxx * h));
}

// 打光
vec3 AddLight(vec3 positon) {
  // 当前着色点的法线
  vec3 n = SDFSphere(positon);
  // 当前着色点到光源的方向
  vec3 lightDir = normalize(LIGHT_POS - positon);
  // 漫反射
  vec3 diffuse = SPHERE_KD * max(dot(lightDir, n), 0.);
  // 环境光
  float amb = 0.15 + dot(-lightDir, n) * 0.2;
  // 最终颜色
  return diffuse + amb;
}

// 光线推进
vec3 RayMarch(vec2 coord) {
  float d = RAYMARCH_NEAR;
  // 从相机视点到当前片元的射线
  vec3 rd = RayDir(coord);
  // 片元颜色
  vec3 color = vec3(0);
  for(int i = 0; i < RAYMARCH_TIME && d < RAYMARCH_FAR; i++) {
    // 光线推进后的点位
    vec3 p = CAMERA_POS + d * rd;
    // 光线推进后的点位到球体的有向距离
    float curD = sdfSphere(p);
    // 若有向距离小于一定的精度,默认此点在球体表面
    if(curD < RAYMARCH_PRECISION) {
      color = AddLight(p);
      break;
    }
    // 距离累加
    d += curD;
  }
  return color;
}

/* 绘图函数,画布中的每个片元都会执行一次,执行方式是并行的。
fragColor 输出参数,用于定义当前片元的颜色。
fragCoord 输入参数,当前片元的位置,原点在画布左下角,右侧边界为画布的像素宽,顶部边界为画布的像素高
*/
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
  // 当前片元的栅格图像位
  vec2 coord = ProjectionCoord(fragCoord.xy);
  // 光线推进
  vec3 color = RayMarch(coord);
  // 最终颜色
  fragColor = vec4(color, 1);
}

4-球体的抗锯齿

我们当前绘制的球体依旧是存在锯齿的。

我们可以使用之前说过的相邻点的抗锯齿。

……

// 抗锯齿 Anti-Aliasing
vec3 RayMarch_anti(vec2 fragCoord) {
  // 初始颜色
  vec3 color = vec3(0);
  // 行列的一半
  float aa2 = float(AA / 2);
  // 逐行列变了
  for(int y = 0; y < AA; y++) {
    for(int x = 0; x < AA; x++) {
      // 基于像素的偏移距离
      vec2 offset = vec2(float(x), float(y)) / float(AA) - aa2;
      // 投影坐标位
      vec2 coord = ProjectionCoord(fragCoord + offset);
      // 累加周围片元的颜色
      color += RayMarch(coord);
    }
  }
  // 返回周围颜色的均值
  return color / float(AA * AA);
}

/* 绘图函数,画布中的每个片元都会执行一次,执行方式是并行的。
fragColor 输出参数,用于定义当前片元的颜色。
fragCoord 输入参数,当前片元的位置,原点在画布左下角,右侧边界为画布的像素宽,顶部边界为画布的像素高
*/
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
  // 光线推进
  vec3 color = RayMarch_anti(fragCoord);
  // 最终颜色
  fragColor = vec4(color, 1);
}

效果如下:

image-20220908124804474

关于以球体为例的SDF模型我们就说到这,接下来咱们会说一下如何给SDF模型打相机。

参考链接:space.bilibili.com/10707223/ch…