Shadertoy 教程 Part 7 - 颜色和3D场景下的多物体绘制


Note: This series blog was translated from Nathan Vaughn's Shaders Language Tutorial and has been authorized by the author. If reprinted or reposted, please be sure to mark the original link and description in the key position of the article after obtaining the author’s consent as well as the translator's. If the article is helpful to you, click this Donation link to buy the author a cup of coffee.
说明:该系列博文翻译自Nathan Vaughn的着色器语言教程。文章已经获得作者翻译授权,如有转载请务必在取得作者和译者同意之后在文章的重点位置标明原文链接以及说明。如果你觉得文章对你有帮助,点击此打赏链接请作者喝一杯咖啡。

朋友们,你们好!欢迎来到Shadertoy教程的第七章。这次我们要为3D场景添加一些颜色,并且还会学到如何在3D场景中添加多个物体例如:地板。

绘制多个3D物体

在上一篇教程当中,我们学会了如何使用Shadertoy 绘制一个球形,但是我们的场景目前还只能绘制一个图形。

重构之前的代码,让 sdScene 函数负责返回场景中最近的一个图形。

const int MAX_MARCHING_STEPS = 255;
const float MIN_DIST = 0.0;
const float MAX_DIST = 100.0;
const float PRECISION = 0.001;

float sdSphere(vec3 p, float r )
{
  vec3 offset = vec3(0, 0, -2);
  return length(p - offset) - r;
}

float sdScene(vec3 p) {
  return sdSphere(p, 1.);
}

float rayMarch(vec3 ro, vec3 rd, float start, float end) {
  float depth = start;

  for (int i = 0; i < MAX_MARCHING_STEPS; i++) {
    vec3 p = ro + depth * rd;
    float d = sdScene(p);
    depth += d;
    if (d < PRECISION || depth > end) break;
  }

  return depth;
}

vec3 calcNormal(in vec3 p) {
    vec2 e = vec2(1.0, -1.0) * 0.0005; // epsilon
    float r = 1.; // radius of sphere
    return normalize(
      e.xyy * sdScene(p + e.xyy) +
      e.yyx * sdScene(p + e.yyx) +
      e.yxy * sdScene(p + e.yxy) +
      e.xxx * sdScene(p + e.xxx));
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = (fragCoord-.5*iResolution.xy)/iResolution.y;
  vec3 backgroundColor = vec3(0.835, 1, 1);

  vec3 col = vec3(0);
  vec3 ro = vec3(0, 0, 3); //射线起点即相机所处的位置 
  vec3 rd = normalize(vec3(uv, -1)); // 射线方向

  float d = rayMarch(ro, rd, MIN_DIST, MAX_DIST); // 球形距离

  if (d > MAX_DIST) {
    col = backgroundColor; // ray didn't hit anything
  } else {
    vec3 p = ro + rd * d; // 利用光线步进计算出的球上的点
    vec3 normal = calcNormal(p);
    vec3 lightPosition = vec3(2, 2, 7);
    vec3 lightDirection = normalize(lightPosition - p);

    // 利用法线方向和射线方向的点积计算漫反射。
    float dif = clamp(dot(normal, lightDirection), 0.3, 1.);

    // 给漫反射乘以一个橘色的颜色值,然后为球的颜色值增加一点背景颜色值,使其看起来更能融入背景。
    col = dif * vec3(1, 0.58, 0.29) + backgroundColor * .2;
  }

  // Output to screen
  fragColor = vec4(col, 1.0);
}
 

请注意我们是如何用sdScence函数替换sdSphere函数的。如要在场景中加入更多的物体,我们可以使用min函数在场景中获取最近的物体。

  float sdScene(vec3 p) {
  float sphereLeft = sdSphere(p, 1.);
  float sphereRight = sdSphere(p, 1.);
  return min(sphereLeft, sphereRight);
}

目前,我们的球都在顶部同一个位置。我们给sdSphere函数添加一个偏移值参数offset

float sdSphere(vec3 p, float r, vec3 offset )
{
  return length(p - offset) - r;
}

然后,我们为给每个球偏移一点位置:

  float sdScene(vec3 p) {
  float sphereLeft = sdSphere(p, 1., vec3(-2.5, 0, -2));
  float sphereRight = sdSphere(p, 1., vec3(2.5, 0, -2));
  return min(sphereLeft, sphereRight);
}

完整的代码如下所示:

  const int MAX_MARCHING_STEPS = 255;
const float MIN_DIST = 0.0;
const float MAX_DIST = 100.0;
const float PRECISION = 0.001;

float sdSphere(vec3 p, float r, vec3 offset )
{
  return length(p - offset) - r;
}

float sdScene(vec3 p) {
  float sphereLeft = sdSphere(p, 1., vec3(-2.5, 0, -2));
  float sphereRight = sdSphere(p, 1., vec3(2.5, 0, -2));
  return min(sphereLeft, sphereRight);
}

float rayMarch(vec3 ro, vec3 rd, float start, float end) {
  float depth = start;

  for (int i = 0; i < MAX_MARCHING_STEPS; i++) {
    vec3 p = ro + depth * rd;
    float d = sdScene(p);
    depth += d;
    if (d < PRECISION || depth > end) break;
  }

  return depth;
}

vec3 calcNormal(in vec3 p) {
    vec2 e = vec2(1.0, -1.0) * 0.0005; // epsilon
    float r = 1.; // radius of sphere
    return normalize(
      e.xyy * sdScene(p + e.xyy) +
      e.yyx * sdScene(p + e.yyx) +
      e.yxy * sdScene(p + e.yxy) +
      e.xxx * sdScene(p + e.xxx));
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = (fragCoord-.5*iResolution.xy)/iResolution.y;
  vec3 backgroundColor = vec3(0.835, 1, 1);

  vec3 col = vec3(0);
  vec3 ro = vec3(0, 0, 3); // ray origin that represents camera position
  vec3 rd = normalize(vec3(uv, -1)); // ray direction

  float d = rayMarch(ro, rd, MIN_DIST, MAX_DIST); // distance to sphere

  if (d > MAX_DIST) {
    col = backgroundColor; // ray didn't hit anything
  } else {
    vec3 p = ro + rd * d; // point on sphere we discovered from ray marching
    vec3 normal = calcNormal(p);
    vec3 lightPosition = vec3(2, 2, 7);
    vec3 lightDirection = normalize(lightPosition - p);

    // Calculate diffuse reflection by taking the dot product of 
    // the normal and the light direction.
    float dif = clamp(dot(normal, lightDirection), 0.3, 1.);

    // Multiply the diffuse reflection value by an orange color and add a bit
    // of the background color to the sphere to blend it more with the background.
    col = dif * vec3(1, 0.58, 0.29) + backgroundColor * .2;
  }

  // Output to screen
  fragColor = vec4(col, 1.0);
}

运行以上的代码,我们就能看到两个对称的黄色的球了。

添加地板

通过下面的方式,我们在距离球下方一个单位的位置添加一个地板。

  float sdFloor(vec3 p) {
  return p.y + 1.;
}

p.y + 1 也可以表示为 p.y - (-1),意味着给地板一个偏移值,让它下调一个单位。接下来,我们调用min函数,就可以把地板加入我们的场景当中了:

  float sdScene(vec3 p) {
    float sphereLeft = sdSphere(p, 1., vec3(-2.5, 0, -2));
    float sphereRight = sdSphere(p, 1., vec3(2.5, 0, -2));
    float res = min(sphereLeft, sphereRight);
    res = min(res, sdFloor(p));
    return res;
}

添加颜色

在Shadertoy中,为3D图形添加颜色的方式有很多种。其中一种方式是通过修改SDF函数,让其返回值中包含图形的距离和颜色,也就是将float类型修改为vec4类型。vec4类型的变量中第一个元素表示“符号距离”,剩下的三个值就是颜色值。因此,我们需要在各个地方修改我们的返回值。完整的代码如下:

const int MAX_MARCHING_STEPS = 255;
const float MIN_DIST = 0.0;
const float MAX_DIST = 100.0;
const float PRECISION = 0.001;

vec4 sdSphere(vec3 p, float r, vec3 offset, vec3 col )
{
  float d = length(p - offset) - r;
  return vec4(d, col);
}

vec4 sdFloor(vec3 p, vec3 col) {
  float d = p.y + 1.;
  return vec4(d, col);
}

vec4 minWithColor(vec4 obj1, vec4 obj2) {
  if (obj2.x < obj1.x) return obj2; // objet的x元素保存了距离符号的值
  return obj1;
}

vec4 sdScene(vec3 p) {
  vec4 sphereLeft = sdSphere(p, 1., vec3(-2.5, 0, -2), vec3(0, .8, .8));
  vec4 sphereRight = sdSphere(p, 1., vec3(2.5, 0, -2), vec3(1, 0.58, 0.29));
  vec4 co = minWithColor(sphereLeft, sphereRight); // co 表示距离最近的包含了符号距离和颜色值的物体
  co = minWithColor(co, sdFloor(p, vec3(0, 1, 0)));
  return co;
}

vec4 rayMarch(vec3 ro, vec3 rd, float start, float end) {
  float depth = start;
  vec4 co; // closest object

  for (int i = 0; i < MAX_MARCHING_STEPS; i++) {
    vec3 p = ro + depth * rd;
    co = sdScene(p);
    depth += co.x;
    if (co.x < PRECISION || depth > end) break;
  }
  
  vec3 col = vec3(co.yzw);

  return vec4(depth, col);
}

vec3 calcNormal(in vec3 p) {
    vec2 e = vec2(1.0, -1.0) * 0.0005; // epsilon
    return normalize(
      e.xyy * sdScene(p + e.xyy).x +
      e.yyx * sdScene(p + e.yyx).x +
      e.yxy * sdScene(p + e.yxy).x +
      e.xxx * sdScene(p + e.xxx).x);
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = (fragCoord-.5*iResolution.xy)/iResolution.y;
  vec3 backgroundColor = vec3(0.835, 1, 1);

  vec3 col = vec3(0);
  vec3 ro = vec3(0, 0, 3); // ray origin that represents camera position
  vec3 rd = normalize(vec3(uv, -1)); // ray direction

  vec4 co = rayMarch(ro, rd, MIN_DIST, MAX_DIST); // closest object

  if (co.x > MAX_DIST) {
    col = backgroundColor; // 射线没有与任何物体相交
  } else {
    vec3 p = ro + rd * co.x; // 光线步进算法中计算出来的地板或者球上的点
    vec3 normal = calcNormal(p);
    vec3 lightPosition = vec3(2, 2, 7);
    vec3 lightDirection = normalize(lightPosition - p);

    float dif = clamp(dot(normal, lightDirection), 0.3, 1.);


    col = dif * co.yzw + backgroundColor * .2;
  }

  // Output to screen
  fragColor = vec4(col, 1.0);
}  

如果要编译器安全编译通过,我们需要修改多处代码。首先要修改的就是SDF返回值的类型,由 float 改为 vec4

  vec4 sdSphere(vec3 p, float r, vec3 offset, vec3 col )
{
  float d = length(p - offset) - r;
  return vec4(d, col);
}

vec4 sdFloor(vec3 p, vec3 col) {
  float d = p.y + 1.;
  return vec4(d, col);
}

现在,这些方法都会接受一个新的color参数,但这样做就让我们的min函数失效了,因为min只能对比float类型的值。因此,我们需要自己创建一个类似min函数的方法来处理这个问题。

  vec4 minWithColor(vec4 obj1, vec4 obj2) {
  if (obj2.x < obj1.x) return obj2;
  return obj1;
}

vec4 sdScene(vec3 p) {
  vec4 sphereLeft = sdSphere(p, 1., vec3(-2.5, 0, -2), vec3(0, .8, .8));
  vec4 sphereRight = sdSphere(p, 1., vec3(2.5, 0, -2), vec3(1, 0.58, 0.29));
  vec4 co = minWithColor(sphereLeft, sphereRight); // co = closest object containing "signed distance" and color
  co = minWithColor(co, sdFloor(p, vec3(0, 1, 0)));
  return co;
}

minWithColor函数与min函数的总体上是相似的。它会接受一个vec4类型的参数,参数中包含需要添加的颜色值和从SDF返回的符号距离。接下来是光线步进算法函数,我们也要去修改它:

  vec4 rayMarch(vec3 ro, vec3 rd, float start, float end) {
  float depth = start;
  vec4 co; // closest object

  for (int i = 0; i < MAX_MARCHING_STEPS; i++) {
    vec3 p = ro + depth * rd;
    co = sdScene(p);
    depth += co.x;
    if (co.x < PRECISION || depth > end) break;
  }
  
  vec3 col = vec3(co.yzw);

  return vec4(depth, col);
}

我们也需要修改 calcNormal 函数,只取sdScene函数返回值的x元素:

  vec3 calcNormal(in vec3 p) {
    vec2 e = vec2(1.0, -1.0) * 0.0005; // epsilon
    return normalize(
      e.xyy * sdScene(p + e.xyy).x +
      e.yyx * sdScene(p + e.yyx).x +
      e.yxy * sdScene(p + e.yxy).x +
      e.xxx * sdScene(p + e.xxx).x);
}

最终,我们修改mainImage函数,使用我们新改的代码:

  void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = (fragCoord-.5*iResolution.xy)/iResolution.y;
  vec3 backgroundColor = vec3(0.835, 1, 1);

  vec3 col = vec3(0);
  vec3 ro = vec3(0, 0, 3);
  vec3 rd = normalize(vec3(uv, -1));

  vec4 co = rayMarch(ro, rd, MIN_DIST, MAX_DIST); 

  if (co.x > MAX_DIST) {
    col = backgroundColor; 
  } else {
    vec3 p = ro + rd * co.x; 
    vec3 normal = calcNormal(p);
    vec3 lightPosition = vec3(2, 2, 7);
    vec3 lightDirection = normalize(lightPosition - p);

    float dif = clamp(dot(normal, lightDirection), 0.3, 1.);

    col = dif * co.yzw + backgroundColor * .2;
  }

  // Output to screen
  fragColor = vec4(col, 1.0);
}

我们用col.x获取符号距离,用col.yzw获取颜色。使用这个方法允许我们在vec4中存储值,就像在其他语言中是数组储值那样。GLSL允许我们使用数组,但是没有Javascript这种类型的语言那么灵活。你需要知道数组中值的个数,并且要保证数组中的值的类型都是相同的。

如果你不认为使用vec4来储存距离和颜色是一种好方法,你也可以使用structs来做到这些事情。Structs 是你用来组织GLSL代码的另外一种方式。Structs有点类似C++语法。如果你熟悉C++和Javascript,那么你可以把structs想像成类和对象的集合。我们来看看它所代表的含义吧:

struct一般包含属性。我们先声明一个Surface吧。

  struct Surface {
    float signedDistance;
    vec3 color;
  };

此处创建了一个函数,返回Surface结构,然后又创建了一个struct的实例:

// This function's return value is of type "Surface"
Surface sdSphere(vec3 p, float r, vec3 offset, vec3 col)
{
  float d = length(p - offset) - r;
  return Surface(d, col); // We're initializing a new "Surface" struct here and then returning it
}

通过点来访问struct的属性

  Surface minWithColor(Surface obj1, Surface obj2) {
    if (obj2.sd < obj1.sd) return obj2; // The sd component of the struct holds the "signed distance" value
    return obj1;
  }

通过学习到的关于structs的新知识,我们就可以来改写代码了:

  const int MAX_MARCHING_STEPS = 255;
const float MIN_DIST = 0.0;
const float MAX_DIST = 100.0;
const float PRECISION = 0.001;

struct Surface {
    float sd; // signed distance value
    vec3 col; // color
};

Surface sdSphere(vec3 p, float r, vec3 offset, vec3 col)
{
  float d = length(p - offset) - r;
  return Surface(d, col);
}

Surface sdFloor(vec3 p, vec3 col) {
  float d = p.y + 1.;
  return Surface(d, col);
}

Surface minWithColor(Surface obj1, Surface obj2) {
  if (obj2.sd < obj1.sd) return obj2; // The sd component of the struct holds the "signed distance" value
  return obj1;
}

Surface sdScene(vec3 p) {
  Surface sphereLeft = sdSphere(p, 1., vec3(-2.5, 0, -2), vec3(0, .8, .8));
  Surface sphereRight = sdSphere(p, 1., vec3(2.5, 0, -2), vec3(1, 0.58, 0.29));
  Surface co = minWithColor(sphereLeft, sphereRight); // co = closest object containing "signed distance" and color
  co = minWithColor(co, sdFloor(p, vec3(0, 1, 0)));
  return co;
}

Surface rayMarch(vec3 ro, vec3 rd, float start, float end) {
  float depth = start;
  Surface co; // closest object

  for (int i = 0; i < MAX_MARCHING_STEPS; i++) {
    vec3 p = ro + depth * rd;
    co = sdScene(p);
    depth += co.sd;
    if (co.sd < PRECISION || depth > end) break;
  }
  
  co.sd = depth;
  
  return co;
}

vec3 calcNormal(in vec3 p) {
    vec2 e = vec2(1.0, -1.0) * 0.0005; // epsilon
    return normalize(
      e.xyy * sdScene(p + e.xyy).sd +
      e.yyx * sdScene(p + e.yyx).sd +
      e.yxy * sdScene(p + e.yxy).sd +
      e.xxx * sdScene(p + e.xxx).sd);
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = (fragCoord-.5*iResolution.xy)/iResolution.y;
  vec3 backgroundColor = vec3(0.835, 1, 1);

  vec3 col = vec3(0);
  vec3 ro = vec3(0, 0, 3); // ray origin that represents camera position
  vec3 rd = normalize(vec3(uv, -1)); // ray direction

  Surface co = rayMarch(ro, rd, MIN_DIST, MAX_DIST); // closest object

  if (co.sd > MAX_DIST) {
    col = backgroundColor; // ray didn't hit anything
  } else {
    vec3 p = ro + rd * co.sd; // point on sphere or floor we discovered from ray marching
    vec3 normal = calcNormal(p);
    vec3 lightPosition = vec3(2, 2, 7);
    vec3 lightDirection = normalize(lightPosition - p);

    // Calculate diffuse reflection by taking the dot product of 
    // the normal and the light direction.
    float dif = clamp(dot(normal, lightDirection), 0.3, 1.);

    // Multiply the diffuse reflection value by an orange color and add a bit
    // of the background color to the sphere to blend it more with the background.
    col = dif * co.col + backgroundColor * .2;
  }

  // Output to screen
  fragColor = vec4(col, 1.0);
}

这段代码可以和我们之前使用vec4改写的代码是同样的效果。依我看,使用struct的方式会更加地容易并且让代码更加的简洁。我们也不需要局限在vec4这种向量,按照你喜欢的方式来即可:

绘制方块带格子的地板

如果你想让地板铺满格子,你需要将地板的颜色调整如下:

  Surface sdScene(vec3 p) {
  Surface sphereLeft = sdSphere(p, 1., vec3(-2.5, 0, -2), vec3(0, .8, .8));
  Surface sphereRight = sdSphere(p, 1., vec3(2.5, 0, -2), vec3(1, 0.58, 0.29));
  Surface co = minWithColor(sphereLeft, sphereRight);

  vec3 floorColor = vec3(1. + 0.7*mod(floor(p.x) + floor(p.z), 2.0));
  co = minWithColor(co, sdFloor(p, floorColor));
  return co;
}

给地板绘制格子帮助人们在视觉上确认3D场景的深度。mod方法经常使用到在创建重复模式或者分割碎片的场景当中。

总结

在本篇教程中,我们学会了如何在3D场景中绘制多个物体,并且给他们赋上颜色。我们也学习到了利用两种技术手段来实现给场景中物体上色,当然上色的手段是多种多样的!使用struct让代码更加结构化。

引用资源

Ray Marching with Unique Colors
Khronos: Arrays
Khronos: Structs
Khronos: mod

相关