Shadertoy 教程 Part 5 - 运用SDF绘制出更多的2D图形
阅读原文时间:2021年11月03日阅读:1

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着色器语言教程。文章已经获得作者翻译授权,如有转载请务必在取得作者译者同意之后在文章的重点位置标明原文链接以及说明。如果你觉得文章对你有帮助,点击此打赏链接请作者喝一杯咖啡。

更新说明:该博文于2021年5月3日经过重新修正。我添加了关于2D符号距离场函数(以下简称SDF)操作的一个新章节,用一种更加简洁的方式替换了所有绘制2D图形的片段代码,然后又加入了一些关于贝赛尔曲线的章节描述。

朋友们,你们好!在本节教程当中,我们将运用2D SDF用基础的图形创建更多的复杂图形。我还会讨论如何绘制更多的基础图形:心型和星型。我将会帮助你使用一系列2D SDFs,这些方法由Inigo Quilez创建,他是Shadertoy的联合创始人。让我们开始吧!

2D SDF: 联合

在上一篇教程中,我们已经见过如何绘制圆和正方形这样的基础图形。这篇教程中我们使用2D SDF 创建更多的图形,只需要将这些基础的图形结合在一起就可以。

让我们以一份简单的绘制2D图形模板的代码作为开始吧:

  vec3 getBackgroundColor(vec2 uv) {
    uv = uv * 0.5 + 0.5; // remap uv from <-0.5,0.5> to <0.25,0.75>
    vec3 gradientStartColor = vec3(1., 0., 1.);
    vec3 gradientEndColor = vec3(0., 1., 1.);
    return mix(gradientStartColor, gradientEndColor, uv.y); // gradient goes from bottom to top
  }

float sdCircle(vec2 uv, float r, vec2 offset) {
  float x = uv.x - offset.x;
  float y = uv.y - offset.y;

  return length(vec2(x, y)) - r;
}

float sdSquare(vec2 uv, float size, vec2 offset) {
  float x = uv.x - offset.x;
  float y = uv.y - offset.y;

  return max(abs(x), abs(y)) - size;
}

vec3 drawScene(vec2 uv) {
  vec3 col = getBackgroundColor(uv);
  float d1 = sdCircle(uv, 0.1, vec2(0., 0.));
  float d2 = sdSquare(uv, 0.1, vec2(0.1, 0));

  float res; // result
  res = d1;

  res = step(0., res); // Same as res > 0. ? 1. : 0.;

  col = mix(vec3(1,0,0), col, res);
  return col;
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = fragCoord/iResolution.xy; // <0, 1>
  uv -= 0.5; // <-0.5,0.5>
  uv.x *= iResolution.x/iResolution.y; // fix aspect ratio

  vec3 col = drawScene(uv);

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

请注意我使用sdCircle 命名方式替换了sdfCircle (如上一章节教程里面所示)。Inigo Quilez 的个人博客上通常在符号距离场函数前加上sd前缀,但在这里我用了sdf让它所代表的语义更加清晰(signed distance fields)SDF。

运行上面的代码,就能看到一个红色圆被绘制到渐变的背景上,和我们在上一节课中学到的一样:

请注意我们在哪里使用的mix函数:

  col = mix(vec3(1,0,0), col, res);

上面这行代码的意思是根据res的值,返回一个红色或者col(当前的背景颜色)。

下面我们将讨论使用各种SDF的场景。我们将看到如何将一个圆形和一个正方形结合在一起的。

联合:结合两个形状

 vec3 drawScene(vec2 uv) {
  vec3 col = getBackgroundColor(uv);
  float d1 = sdCircle(uv, 0.1, vec2(0., 0.));
  float d2 = sdSquare(uv, 0.1, vec2(0.1, 0));

  float res; // result
  res = min(d1, d2); // union

  res = step(0., res); // Same as res > 0. ? 1. : 0.;

  col = mix(vec3(1,0,0), col, res);
  return col;
}

交叉:只取两个形状的交叉部分

 vec3 drawScene(vec2 uv) {
  vec3 col = getBackgroundColor(uv);
  float d1 = sdCircle(uv, 0.1, vec2(0., 0.));
  float d2 = sdSquare(uv, 0.1, vec2(0.1, 0));

  float res; // result
  res = max(d1, d2); // intersection

  res = step(0., res); // Same as res > 0. ? 1. : 0.;

  col = mix(vec3(1,0,0), col, res);
  return col;
}

裁剪1: 用d1图形裁剪d2图形

  vec3 drawScene(vec2 uv) {
  vec3 col = getBackgroundColor(uv);
  float d1 = sdCircle(uv, 0.1, vec2(0., 0.));
  float d2 = sdSquare(uv, 0.1, vec2(0.1, 0));

  float res; // result
  res = max(-d1, d2); // subtraction - subtract d1 from d2

  res = step(0., res); // Same as res > 0. ? 1. : 0.;

  col = mix(vec3(1,0,0), col, res);
  return col;
}

裁剪2:用d2图形裁剪d1图形

  vec3 drawScene(vec2 uv) {
  vec3 col = getBackgroundColor(uv);
  float d1 = sdCircle(uv, 0.1, vec2(0., 0.));
  float d2 = sdSquare(uv, 0.1, vec2(0.1, 0));

  float res; // result
  res = max(d1, -d2); // subtraction - subtract d2 from d1

  res = step(0., res); // Same as res > 0. ? 1. : 0.;

  col = mix(vec3(1,0,0), col, res);
  return col;
}

XOR: 执行OR指令只会裁剪取两个图形重叠部分保留它们不交差的部分。

vec3 drawScene(vec2 uv) {
  vec3 col = getBackgroundColor(uv);
  float d1 = sdCircle(uv, 0.1, vec2(0., 0.));
  float d2 = sdSquare(uv, 0.1, vec2(0.1, 0));

  float res; // result
  res = max(min(d1, d2), -max(d1, d2)); // xor

  res = step(0., res); // Same as res > 0. ? 1. : 0.;

  col = mix(vec3(1,0,0), col, res);
  return col;
}

我们也可以创建 smooth方法, 让两个图形结合的边缘趋于“平滑”。这些操作经常在3D图形中被使用到,它们同样适用2D图形:

在代码中加入上面的函数:

  // smooth min
float smin(float a, float b, float k) {
  float h = clamp(0.5+0.5*(b-a)/k, 0.0, 1.0);
  return mix(b, a, h) - k*h*(1.0-h);
}

// smooth max
float smax(float a, float b, float k) {
  return -smin(-a, -b, k);
}

平滑结合(Soomth union): 让两个相交的物体结合在一起,但是过渡效果会更加平滑。

 vec3 drawScene(vec2 uv) {
  vec3 col = getBackgroundColor(uv);
  float d1 = sdCircle(uv, 0.1, vec2(0., 0.));
  float d2 = sdSquare(uv, 0.1, vec2(0.1, 0));

  float res; // result
  res = smin(d1, d2, 0.05); // smooth union

  res = step(0., res); // Same as res > 0. ? 1. : 0.;

  col = mix(vec3(1,0,0), col, res);
  return col;
}

(平滑裁剪)Smooth intersection: 当两个物体交叉时,只取两个物体交互的部分,效果趋于平滑:

  vec3 drawScene(vec2 uv) {
  vec3 col = getBackgroundColor(uv);
  float d1 = sdCircle(uv, 0.1, vec2(0., 0.));
  float d2 = sdSquare(uv, 0.1, vec2(0.1, 0));

  float res; // result
  res = smax(d1, d2, 0.05); // smooth intersection

  res = step(0., res); // Same as res > 0. ? 1. : 0.;

  col = mix(vec3(1,0,0), col, res);
  return col;
}

下面是完整的代码,随意注释或者代开注释来观察效果。

    // smooth min
float smin(float a, float b, float k) {
  float h = clamp(0.5+0.5*(b-a)/k, 0.0, 1.0);
  return mix(b, a, h) - k*h*(1.0-h);
}

// smooth max
float smax(float a, float b, float k) {
  return -smin(-a, -b, k);
}

vec3 getBackgroundColor(vec2 uv) {
  uv = uv * 0.5 + 0.5; // remap uv from <-0.5,0.5> to <0.25,0.75>
  vec3 gradientStartColor = vec3(1., 0., 1.);
  vec3 gradientEndColor = vec3(0., 1., 1.);
  return mix(gradientStartColor, gradientEndColor, uv.y); // gradient goes from bottom to top
}

float sdCircle(vec2 uv, float r, vec2 offset) {
  float x = uv.x - offset.x;
  float y = uv.y - offset.y;

  return length(vec2(x, y)) - r;
}

float sdSquare(vec2 uv, float size, vec2 offset) {
  float x = uv.x - offset.x;
  float y = uv.y - offset.y;

  return max(abs(x), abs(y)) - size;
}

vec3 drawScene(vec2 uv) {
  vec3 col = getBackgroundColor(uv);
  float d1 = sdCircle(uv, 0.1, vec2(0., 0.));
  float d2 = sdSquare(uv, 0.1, vec2(0.1, 0));

  float res; // result
  res = d1;
  //res = d2;
  //res = min(d1, d2); // union
  //res = max(d1, d2); // intersection
  //res = max(-d1, d2); // subtraction - subtract d1 from d2
  //res = max(d1, -d2); // subtraction - subtract d2 from d1
  //res = max(min(d1, d2), -max(d1, d2)); // xor
  //res = smin(d1, d2, 0.05); // smooth union
  //res = smax(d1, d2, 0.05); // smooth intersection

  res = step(0., res); // Same as res > 0. ? 1. : 0.;

  col = mix(vec3(1,0,0), col, res);
  return col;
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = fragCoord/iResolution.xy; // <0, 1>
  uv -= 0.5; // <-0.5,0.5>
  uv.x *= iResolution.x/iResolution.y; // fix aspect ratio

  vec3 col = drawScene(uv);

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

2D SDF 操作:定位

Inigo Quilez的3D SDFs 界面描述了一系列定位3D图形的方法,这些方法同样适用于2D场景。我将会在第14章节中讨论3D场景。在本次教程中,我会通过介绍2D SDF以帮助我们节省时间。

opSymX方法在绘制对称的场景的时候非常有用。这个操作帮助你沿着x轴复制出一个2D图形。如果我们的圆设置了便宜量(0.2,0),我们就会在(-0.2,0)的位置得到一个对称的图形。

float opSymX(vec2 p, float r)
{
  p.x = abs(p.x);
  return sdCircle(p, r, vec2(0.2, 0));
}

vec3 drawScene(vec2 uv) {
  vec3 col = getBackgroundColor(uv);

  float res; // result
  res = opSymX(uv, 0.1);

  res = step(0., res);
  col = mix(vec3(1,0,0), col, res);
  return col;
}

我们也可以在y轴执行一个类似的方法:

  float opSymY(vec2 p, float r)
{
  p.y = abs(p.y);
  return sdCircle(p, r, vec2(0, 0.2));
}

vec3 drawScene(vec2 uv) {
  vec3 col = getBackgroundColor(uv);

  float res; // result
  res = opSymY(uv, 0.1);

  res = step(0., res);
  col = mix(vec3(1,0,0), col, res);
  return col;
}

如果你想沿着两个轴做对称处理,我们可以也可以使用opSymXY函数。这个操作会沿着x轴和y轴复分别制出一个圆形得到四个对称的圆。它们的坐标分别是vec2(0.2, 0.2), vec2(0.2, -0.2), vec2(-0.2, -0.2)和vec2(-0.2, 0.2);

  float opSymXY(vec2 p, float r)
{
  p = abs(p);
  return sdCircle(p, r, vec2(0.2));
}

vec3 drawScene(vec2 uv) {
  vec3 col = getBackgroundColor(uv);

  float res; // result
  res = opSymXY(uv, 0.1);

  res = step(0., res);
  col = mix(vec3(1,0,0), col, res);
  return col;
}

如果想要沿着一个或者多个坐标轴创建一个无限数量的2D图形,你可以使用opRep操作来重复你的创建动作。参数c,用来控制每个轴上图形的数量:

  float opRep(vec2 p, float r, vec2 c)
{
  vec2 q = mod(p+0.5*c,c)-0.5*c;
  return sdCircle(q, r, vec2(0));
}

vec3 drawScene(vec2 uv) {
  vec3 col = getBackgroundColor(uv);

  float res; // result
  res = opRep(uv, 0.05, vec2(0.2, 0.2));

  res = step(0., res);
  col = mix(vec3(1,0,0), col, res);
  return col;
}

如果你想重复2d图形多次而不是无限次,你可以使用opRepLim操作,参数c,是一个浮点类型的数字,控制这每个重复图形之间的间距。参数l,是一个向量,控制我们需要沿着给定的周重复多少次。例如vec2(2,2),沿着负轴和正轴,绘制多一个圆。

  float opRepLim(vec2 p, float r, float c, vec2 l)
{
  vec2 q = p-c*clamp(round(p/c),-l,l);
  return sdCircle(q, r, vec2(0));
}

vec3 drawScene(vec2 uv) {
  vec3 col = getBackgroundColor(uv);

  float res; // result
  res = opRepLim(uv, 0.05, 0.15, vec2(2, 2));

  res = step(0., res);
  col = mix(vec3(1,0,0), col, res);
  return col;
}

通过乘以变量p(即uv坐标),然后加上SDF返回的一个值,来对图形执行扭曲和形变效果。在opDisplace函数当中,通过替换p值然后加上SDF返回的值,你可以创建任意类型的数学运算。

float opDisplace(vec2 p, float r)
{
  float d1 = sdCircle(p, r, vec2(0));
  float s = 0.5; // scaling factor

  float d2 = sin(s * p.x * 1.8); // Some arbitrary values I played around with

  return d1 + d2;
}

vec3 drawScene(vec2 uv) {
  vec3 col = getBackgroundColor(uv);

  float res; // result
  res = opDisplace(uv, 0.1); // Kinda looks like an egg

  res = step(0., res);
  col = mix(vec3(1,0,0), col, res);
  return col;
}

下面是完整的代码,通过打开或者关闭注释你可以看到你想要的效果:

  vec3 getBackgroundColor(vec2 uv) {
  uv = uv * 0.5 + 0.5; // remap uv from <-0.5,0.5> to <0.25,0.75>
  vec3 gradientStartColor = vec3(1., 0., 1.);
  vec3 gradientEndColor = vec3(0., 1., 1.);
  return mix(gradientStartColor, gradientEndColor, uv.y); // gradient goes from bottom to top
}

float sdCircle(vec2 uv, float r, vec2 offset) {
  float x = uv.x - offset.x;
  float y = uv.y - offset.y;

  return length(vec2(x, y)) - r;
}

float opSymX(vec2 p, float r)
{
  p.x = abs(p.x);
  return sdCircle(p, r, vec2(0.2, 0));
}

float opSymY(vec2 p, float r)
{
  p.y = abs(p.y);
  return sdCircle(p, r, vec2(0, 0.2));
}

float opSymXY(vec2 p, float r)
{
  p = abs(p);
  return sdCircle(p, r, vec2(0.2));
}

float opRep(vec2 p, float r, vec2 c)
{
  vec2 q = mod(p+0.5*c,c)-0.5*c;
  return sdCircle(q, r, vec2(0));
}

float opRepLim(vec2 p, float r, float c, vec2 l)
{
  vec2 q = p-c*clamp(round(p/c),-l,l);
  return sdCircle(q, r, vec2(0));
}

float opDisplace(vec2 p, float r)
{
  float d1 = sdCircle(p, r, vec2(0));
  float s = 0.5; // scaling factor

  float d2 = sin(s * p.x * 1.8); // Some arbitrary values I played around with

  return d1 + d2;
}

vec3 drawScene(vec2 uv) {
  vec3 col = getBackgroundColor(uv);

  float res; // result
  res = opSymX(uv, 0.1);
  //res = opSymY(uv, 0.1);
  //res = opSymXY(uv, 0.1);
  //res = opRep(uv, 0.05, vec2(0.2, 0.2));
  //res = opRepLim(uv, 0.05, 0.15, vec2(2, 2));
  //res = opDisplace(uv, 0.1);

  res = step(0., res);
  col = mix(vec3(1,0,0), col, res);
  return col;
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = fragCoord/iResolution.xy; // <0, 1>
  uv -= 0.5; // <-0.5,0.5>
  uv.x *= iResolution.x/iResolution.y; // fix aspect ratio

  vec3 col = drawScene(uv);

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

抗锯齿

如果你需要加入抗锯齿效果,你可以使用smoothstep函数给每个形状的边缘设置平滑的效果。smoothstep函数接受三个参数,当条件edge0 < x < edge1满足时,执行一个在0和1之前进行的Hermite插值操作。

edge0: 指定Hermite 函数的 最小值

edge1: 指定Hermite 函数的 最大值

x:  指定需要进行差值操作的原始值

t = clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0);
return t * t * (3.0 - 2.0 * t);

提示:文档上说`edge0 `如果大于或者等于`edge1`,则`smoothstep`函数会返回一个`undefined`,这是错误的。即使edg0是大于edge1,`smoothstep`函数依旧会返回一个插值函数。

如果你还是感到困惑,这篇文章可以帮助你可视化smoothstep函数的结果。本质上,它的行为和step函数一样,只需要多一步骤操作而已。

让我们把step函数替换为smoothstep函数吧,来看看它是如何将一个圆和一个正方形结合在一起的。

  vec3 getBackgroundColor(vec2 uv) {
  uv = uv * 0.5 + 0.5; // remap uv from <-0.5,0.5> to <0.25,0.75>
  vec3 gradientStartColor = vec3(1., 0., 1.);
  vec3 gradientEndColor = vec3(0., 1., 1.);
  return mix(gradientStartColor, gradientEndColor, uv.y); // gradient goes from bottom to top
}

float sdCircle(vec2 uv, float r, vec2 offset) {
  float x = uv.x - offset.x;
  float y = uv.y - offset.y;

  return length(vec2(x, y)) - r;
}

float sdSquare(vec2 uv, float size, vec2 offset) {
  float x = uv.x - offset.x;
  float y = uv.y - offset.y;

  return max(abs(x), abs(y)) - size;
}

vec3 drawScene(vec2 uv) {
  vec3 col = getBackgroundColor(uv);
  float d1 = sdCircle(uv, 0.1, vec2(0., 0.));
  float d2 = sdSquare(uv, 0.1, vec2(0.1, 0));

  float res; // result
  res = min(d1, d2); // union

  res = smoothstep(0., 0.02, res); // antialias entire result

  col = mix(vec3(1,0,0), col, res);
  return col;
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = fragCoord/iResolution.xy; // <0, 1>
  uv -= 0.5; // <-0.5,0.5>
  uv.x *= iResolution.x/iResolution.y; // fix aspect ratio

  vec3 col = drawScene(uv);

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

我们可以看到画布上出现了一些模糊的效果。

smoothstep函数帮助我们在两个颜色之间进行平滑的过渡,对实施抗锯齿很有效。你也可以看看其他人是如果使用smoothstep函数制造神奇的效果。它经常被用到着色器中。

画一颗心

在本节中,我会教你如何用Shadertoy绘制出一颗心。请记住,绘制心形的方法有很多,我们在这里将展示的方法是由 Wolfram MathWord 提供的一个方程式来绘制的。

如果我们要将这个心形曲线做位移变化,那么我们就需要在操作之前,就从x和y元素中抽取一个值:

  s = x - offsetX
t = y - offsetY

(s^2 + t^2 - 1)^3 - s^2 * t^3 = 0

x = x-coordinate on graph
y = y-coordinate on graph

使用我们在Desmos上创建的示例,你可以任意操作心形曲线的位移。

现在,我们如何在Shadertoy中创建一个SDF呢?我们只需要简单地将公式左侧的值赋值给d. 然后,我们执行再第四节教程中学到的东西就行了。

  float sdHeart(vec2 uv, float size, vec2 offset) {
  float x = uv.x - offset.x;
  float y = uv.y - offset.y;
  float xx = x * x;
  float yy = y * y;
  float yyy = yy * y;
  float group = xx + yy - size;
  float d = group * group * group - xx * yyy;

  return d;
}

vec3 drawScene(vec2 uv) {
  vec3 col = vec3(1);
  float heart = sdHeart(uv, 0.04, vec2(0));

  col = mix(vec3(1, 0, 0), col, step(0., heart));

  return col;
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = fragCoord/iResolution.xy; // <0, 1>
  uv -= 0.5; // <-0.5,0.5>
  uv.x *= iResolution.x/iResolution.y; // fix aspect ratio

  vec3 col = drawScene(uv);

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

理解pow函数

你也许会奇怪为什么我用一种奇怪的方式创建一个sdHeart函数。为什么不用pow函数呢?pow(x, y)函数接收两个参数,底数x和指数y。

如果你试着使用pow函数,你会发现这颗心会变得奇怪。

  float sdHeart(vec2 uv, float size, vec2 offset) {
  float x = uv.x - offset.x;
  float y = uv.y - offset.y;
  float group = pow(x,2.) + pow(y,2.) - size;
  float d = pow(group,3.) - pow(x,2.) * pow(y,3.);

  return d;
}

好吧,看起来是有点不对劲。如果你把它在情人节送给别人,别人会认为这是一个inkblot test

那么,为什么pow(x, y)函数如此奇怪呢?如果你仔细看这个函数的文档说明,你会发现这个函数会返回一个undefined如果x小于0或者x等于0或者y小于等于0.

请注意,pow函数在不同的编译器和硬件上的表现行为是不一样的,所以,可能在其他平台上不会有像在Shadertoy上这样的问题,或者有着其他的问题。

因为我们的坐标系被重置成xy,我们有时候就会让pow函数返回一个undefined结果。在Shadertoy中,编译器使用undefined数学运算会导致让人很困惑的结果。

我们可以通过在画布上用颜色做调试,看看在Shadertoy中如果将undefined用于数学运算会产生哪些怪异的行为。

  void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = fragCoord/iResolution.xy; // <0, 1>
  uv -= 0.5; // <-0.5,0.5>

  vec3 col = vec3(pow(-0.5, 1.));
  col += 0.5;

  fragColor = vec4(col,1.0);
  // Screen is gray which means undefined is treated as zero
}

undefiend减去一个数值:

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = fragCoord/iResolution.xy; // <0, 1>
  uv -= 0.5; // <-0.5,0.5>

  vec3 col = vec3(pow(-0.5, 1.));
  col -= -0.5;

  fragColor = vec4(col,1.0);
  // Screen is gray which means undefined is treated as zero
}

undefined中乘以一个数值:

  void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = fragCoord/iResolution.xy; // <0, 1>
  uv -= 0.5; // <-0.5,0.5>

  vec3 col = vec3(pow(-0.5, 1.));
  col *= 1.;

  fragColor = vec4(col,1.0);
  // Screen is black which means undefined is treated as zero
}

让我们给undefined除以一个数值:

  void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = fragCoord/iResolution.xy; // <0, 1>
  uv -= 0.5; // <-0.5,0.5>

  vec3 col = vec3(pow(-0.5, 1.));
  col /= 1.;

  fragColor = vec4(col,1.0);
  // Screen is black which means undefined is treated as zero
}

通过上面的实验我们可以观察并且确认,当undefined作为数值在计算公式时,它是被当作0的。但这个种情况还是要视你的编译器和图形处理器的情况而定。我们就拿sdHeart这个函数来澄清这一点吧:

  float sdHeart(vec2 uv, float size, vec2 offset) {
  float x = uv.x - offset.x;
  float y = uv.y - offset.y;
  float group = dot(x,x) + dot(y,y) - size;
  float d = group * dot(group, group) - dot(x,x) * dot(y,y) * y;

  return d;
}

如果要求一个数值的平方,常用的方式是使用dot函数计算向量和自身的点积。我们可以把sdHeart函数改得清晰一点。调用dot(x, x)就是求x的平方,但却没有pow函数带来的烦恼。

使用 sdStart5 SDF

在使用Shadertoy的整个使用过程,Inigo Quilez 为开发者提供了一系列2D SDFs 和3D SDFs。我们将会在本节中将讨论如何使用这些函数,并且结合我们在第四篇教程中学习到的方法一起来绘制2D 图形。

我们使用SDF 创建图形,这些图形我们称之为基础图形,因为他们是绘制更抽象图形的基石。对于2D场景来说,我们很容易在画布上绘制图形,然而我们创建3D图形就显得有些复杂,这个我们在后面的时候谈到它们:

让我们先用SDF来画一个星吧,因为画星星是很有趣的。导航到Inigo Quilez's 的网页,让后拉下滚动条,找到 “Star 5 -exact”。就可以看到下面的定义:

  float sdStar5(in vec2 p, in float r, in float rf)
{
  const vec2 k1 = vec2(0.809016994375, -0.587785252292);
  const vec2 k2 = vec2(-k1.x,k1.y);
  p.x = abs(p.x);
  p -= 2.0*max(dot(k1,p),0.0)*k1;
  p -= 2.0*max(dot(k2,p),0.0)*k2;
  p.x = abs(p.x);
  p.y -= r;
  vec2 ba = rf*vec2(-k1.y,k1.x) - vec2(0,1);
  float h = clamp( dot(p,ba)/dot(ba,ba), 0.0, r );
  return length(p-ba*h) * sign(p.y*ba.x-p.x*ba.y);
}

请先别管函数中定义的in关键字,如果你要移除也没问题,in在制动器中没有被要求强制引用。

在Shadertoy中新建一个着色器,然后使用下面的代码:

  float sdStar5(in vec2 p, in float r, in float rf)
{
  const vec2 k1 = vec2(0.809016994375, -0.587785252292);
  const vec2 k2 = vec2(-k1.x,k1.y);
  p.x = abs(p.x);
  p -= 2.0*max(dot(k1,p),0.0)*k1;
  p -= 2.0*max(dot(k2,p),0.0)*k2;
  p.x = abs(p.x);
  p.y -= r;
  vec2 ba = rf*vec2(-k1.y,k1.x) - vec2(0,1);
  float h = clamp( dot(p,ba)/dot(ba,ba), 0.0, r );
  return length(p-ba*h) * sign(p.y*ba.x-p.x*ba.y);
}

vec3 drawScene(vec2 uv) {
  vec3 col = vec3(0);
  float star = sdStar5(uv, 0.12, 0.45);

  col = mix(vec3(1, 1, 0), col, step(0., star));

  return col;
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = fragCoord/iResolution.xy; // <0, 1>
  uv -= 0.5; // <-0.5,0.5>
  uv.x *= iResolution.x/iResolution.y; // fix aspect ratio

  vec3 col = drawScene(uv);

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

运行上面的代码,我们就可以看到一个闪亮的黄色的星星!

我们忘记一件事情了,需要给一个sdStart5函数一个初始的偏移值。我们添加一个offset参数,然后用p减去这个偏移数值,p就是我们传入到函数中的UV坐标。

我们的代码最终看起来就是这个样子:

float sdStar5(in vec2 p, in float r, in float rf, vec2 offset)
{
  p -= offset; // This will subtract offset.x from p.x and subtract offset.y from p.y
  const vec2 k1 = vec2(0.809016994375, -0.587785252292);
  const vec2 k2 = vec2(-k1.x,k1.y);
  p.x = abs(p.x);
  p -= 2.0*max(dot(k1,p),0.0)*k1;
  p -= 2.0*max(dot(k2,p),0.0)*k2;
  p.x = abs(p.x);
  p.y -= r;
  vec2 ba = rf*vec2(-k1.y,k1.x) - vec2(0,1);
  float h = clamp( dot(p,ba)/dot(ba,ba), 0.0, r );
  return length(p-ba*h) * sign(p.y*ba.x-p.x*ba.y);
}

vec3 drawScene(vec2 uv) {
  vec3 col = vec3(0);
  float star = sdStar5(uv, 0.12, 0.45, vec2(0.2, 0)); // Add an offset to shift the star's position

  col = mix(vec3(1, 1, 0), col, step(0., star));

  return col;
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = fragCoord/iResolution.xy; // <0, 1>
  uv -= 0.5; // <-0.5,0.5>
  uv.x *= iResolution.x/iResolution.y; // fix aspect ratio

  vec3 col = drawScene(uv);

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

使用 sdBox SDF

绘制正方形也很常见,所以我们选择一个“Box - exact.”。它的定义如下:

  float sdBox( in vec2 p, in vec2 b )
{
  vec2 d = abs(p)-b;
  return length(max(d,0.0)) + min(max(d.x,d.y),0.0);
}

我们为其添加一个偏移参数:

  float sdBox( in vec2 p, in vec2 b, vec2 offset )
{
  p -= offset;
  vec2 d = abs(p)-b;
  return length(max(d,0.0)) + min(max(d.x,d.y),0.0);
}

现在,我们可以准确地渲染出盒子和星星:

  float sdBox( in vec2 p, in vec2 b, vec2 offset )
{
  p -= offset;
  vec2 d = abs(p)-b;
  return length(max(d,0.0)) + min(max(d.x,d.y),0.0);
}

float sdStar5(in vec2 p, in float r, in float rf, vec2 offset)
{
  p -= offset; // This will subtract offset.x from p.x and subtract offset.y from p.y
  const vec2 k1 = vec2(0.809016994375, -0.587785252292);
  const vec2 k2 = vec2(-k1.x,k1.y);
  p.x = abs(p.x);
  p -= 2.0*max(dot(k1,p),0.0)*k1;
  p -= 2.0*max(dot(k2,p),0.0)*k2;
  p.x = abs(p.x);
  p.y -= r;
  vec2 ba = rf*vec2(-k1.y,k1.x) - vec2(0,1);
  float h = clamp( dot(p,ba)/dot(ba,ba), 0.0, r );
  return length(p-ba*h) * sign(p.y*ba.x-p.x*ba.y);
}

vec3 drawScene(vec2 uv) {
  vec3 col = vec3(0);
  float box = sdBox(uv, vec2(0.2, 0.1), vec2(-0.2, 0));
  float star = sdStar5(uv, 0.12, 0.45, vec2(0.2, 0));

  col = mix(vec3(1, 1, 0), col, step(0., star));
  col = mix(vec3(0, 0, 1), col, step(0., box));

  return col;
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = fragCoord/iResolution.xy; // <0, 1>
  uv -= 0.5; // <-0.5,0.5>
  uv.x *= iResolution.x/iResolution.y; // fix aspect ratio

  vec3 col = drawScene(uv);

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

从Inigo Quilez 的网站里面选择2D SDF,对它们进行一点点微调,给定一个偏移量,然后就可以把它们绘制到画布上啦。需要注意,有些函数定义在3D SDF界面上, 你需要去那里找到:

float dot2( in vec2 v ) { return dot(v,v); }
float dot2( in vec3 v ) { return dot(v,v); }
float ndot( in vec2 a, in vec2 b ) { return a.x*b.x - a.y*b.y; }

使用 sdSegment SDF

Inigo Quilez 的网站中有一些 2D SDF 是绘制线段或者曲线的。例如名为“Segment-exact”的SDF,它的定义如下:

  float sdSegment( in vec2 p, in vec2 a, in vec2 b )
{
  vec2 pa = p-a, ba = b-a;
  float h = clamp( dot(pa,ba)/dot(ba,ba), 0.0, 1.0 );
  return length( pa - ba*h );
}

我们看看使用这个SDF会发生什么:

  float sdSegment( in vec2 p, in vec2 a, in vec2 b )
{
  vec2 pa = p-a, ba = b-a;
  float h = clamp( dot(pa,ba)/dot(ba,ba), 0.0, 1.0 );
  return length( pa - ba*h );
}

vec3 drawScene(vec2 uv) {
  vec3 col = vec3(0);
  float segment = sdSegment(uv, vec2(0, 0), vec2(0, .2));

  col = mix(vec3(1, 1, 1), col, step(0., segment));

  return col;
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = fragCoord/iResolution.xy; // <0, 1>
  uv -= 0.5; // <-0.5,0.5>
  uv.x *= iResolution.x/iResolution.y; // fix aspect ratio

  vec3 col = drawScene(uv);

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

运行以上的代码,我们就得到了一个纯黑色的画布,出现这种情况,是因为线条太细了,以至于无法在画布上观察到它。为了给它添加一个粗细值,我们从返回的结果中减去一个值:

float sdSegment( in vec2 p, in vec2 a, in vec2 b )
{
  vec2 pa = p-a, ba = b-a;
  float h = clamp( dot(pa,ba)/dot(ba,ba), 0.0, 1.0 );
  return length( pa - ba*h );
}

vec3 drawScene(vec2 uv) {
  vec3 col = vec3(0);
  float segment = sdSegment(uv, vec2(0, 0), vec2(0, 0.2));

  col = mix(vec3(1, 1, 1), col, step(0., segment - 0.02)); // Subtract 0.02 from the returned "signed distance" value of the segment

  return col;
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = fragCoord/iResolution.xy; // <0, 1>
  uv -= 0.5; // <-0.5,0.5>
  uv.x *= iResolution.x/iResolution.y; // fix aspect ratio

  vec3 col = drawScene(uv);

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

现在,我们可以看到我们的片段了!他的原点位置是左边(0,0),结束位置是(0,0.2)。通过修改入参ab,我们在其中调用sdSegment函数生成的线段或者移动拉伸。如果你想要这条线段看起来粗一点的话,你可以将宽度调整为0.02。

也可用使用smoothstep函数让线段的边缘变模糊。

  float sdSegment( in vec2 p, in vec2 a, in vec2 b )
{
  vec2 pa = p-a, ba = b-a;
  float h = clamp( dot(pa,ba)/dot(ba,ba), 0.0, 1.0 );
  return length( pa - ba*h );
}

vec3 drawScene(vec2 uv) {
  vec3 col = vec3(0);
  float segment = sdSegment(uv, vec2(0, 0), vec2(0, .2));

  col = mix(vec3(1, 1, 1), col, smoothstep(0., 0.02, segment));

  return col;
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = fragCoord/iResolution.xy; // <0, 1>
  uv -= 0.5; // <-0.5,0.5>
  uv.x *= iResolution.x/iResolution.y; // fix aspect ratio

  vec3 col = drawScene(uv);

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

现在,线段看起来是在发亮!

使用 sdBezier SDF

Inigo Quilez 的网站上同样也有绘制贝塞尔曲线的SDF。找到 "Quadratic Bezier - exact",它的定义如下:

  float sdBezier( in vec2 pos, in vec2 A, in vec2 B, in vec2 C )
{
    vec2 a = B - A;
    vec2 b = A - 2.0*B + C;
    vec2 c = a * 2.0;
    vec2 d = A - pos;
    float kk = 1.0/dot(b,b);
    float kx = kk * dot(a,b);
    float ky = kk * (2.0*dot(a,a)+dot(d,b)) / 3.0;
    float kz = kk * dot(d,a);
    float res = 0.0;
    float p = ky - kx*kx;
    float p3 = p*p*p;
    float q = kx*(2.0*kx*kx-3.0*ky) + kz;
    float h = q*q + 4.0*p3;
    if( h >= 0.0)
    {
        h = sqrt(h);
        vec2 x = (vec2(h,-h)-q)/2.0;
        vec2 uv = sign(x)*pow(abs(x), vec2(1.0/3.0));
        float t = clamp( uv.x+uv.y-kx, 0.0, 1.0 );
        res = dot2(d + (c + b*t)*t);
    }
    else
    {
        float z = sqrt(-p);
        float v = acos( q/(p*z*2.0) ) / 3.0;
        float m = cos(v);
        float n = sin(v)*1.732050808;
        vec3  t = clamp(vec3(m+m,-n-m,n-m)*z-kx,0.0,1.0);
        res = min( dot2(d+(c+b*t.x)*t.x),
                   dot2(d+(c+b*t.y)*t.y) );
        // the third root cannot be the closest
        // res = min(res,dot2(d+(c+b*t.z)*t.z));
    }
    return sqrt( res );
}

这是一个很长的函数!请注意这个函数使用一个工具方法,dot2,它是在3D SDF界面上定义。

  float dot2( in vec2 v ) { return dot(v,v); }

贝塞尔曲线接受三个控制点。在2D场景中,每个控制点会是一个vec2的向量,拥有x和y元素。你可以操作这些控制点,使用我在Desmos上创建的示例。

sdSegment一样,我们为SDF返回的结果减去一个值,看看合适的曲线。现在,我们开始用GLSL代码绘制一个贝塞尔曲线:

float dot2( in vec2 v ) { return dot(v,v); }

float sdBezier( in vec2 pos, in vec2 A, in vec2 B, in vec2 C )
{
    vec2 a = B - A;
    vec2 b = A - 2.0*B + C;
    vec2 c = a * 2.0;
    vec2 d = A - pos;
    float kk = 1.0/dot(b,b);
    float kx = kk * dot(a,b);
    float ky = kk * (2.0*dot(a,a)+dot(d,b)) / 3.0;
    float kz = kk * dot(d,a);
    float res = 0.0;
    float p = ky - kx*kx;
    float p3 = p*p*p;
    float q = kx*(2.0*kx*kx-3.0*ky) + kz;
    float h = q*q + 4.0*p3;
    if( h >= 0.0)
    {
        h = sqrt(h);
        vec2 x = (vec2(h,-h)-q)/2.0;
        vec2 uv = sign(x)*pow(abs(x), vec2(1.0/3.0));
        float t = clamp( uv.x+uv.y-kx, 0.0, 1.0 );
        res = dot2(d + (c + b*t)*t);
    }
    else
    {
        float z = sqrt(-p);
        float v = acos( q/(p*z*2.0) ) / 3.0;
        float m = cos(v);
        float n = sin(v)*1.732050808;
        vec3  t = clamp(vec3(m+m,-n-m,n-m)*z-kx,0.0,1.0);
        res = min( dot2(d+(c+b*t.x)*t.x),
                   dot2(d+(c+b*t.y)*t.y) );
        // the third root cannot be the closest
        // res = min(res,dot2(d+(c+b*t.z)*t.z));
    }
    return sqrt( res );
}

vec3 drawScene(vec2 uv) {
    vec3 col = vec3(0);
    vec2 A = vec2(0, 0);
    vec2 B = vec2(0.2, 0);
    vec2 C = vec2(0.2, 0.2);
    float curve = sdBezier(uv, A, B, C);

    col = mix(vec3(1, 1, 1), col, step(0., curve - 0.01));

    return col;
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    vec2 uv = fragCoord/iResolution.xy; // <0, 1>
    uv -= 0.5; // <-0.5,0.5>
    uv.x *= iResolution.x/iResolution.y; // fix aspect ratio

    vec3 col = drawScene(uv);

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

运行以上的代码,你会看到画布上出现了一条贝塞尔曲线:

尝试去修改这些控制点吧!请记住,你可以使用Desmos graph来帮助你。

你可以使用2D SDF 和贝塞尔曲线一起,制造出一些有趣的效果。用贝塞尔曲线去裁剪一个圆形,制造类似网球的效果。要具体创造什么效果,最终决定还是在你自己。

下面的代码是制造这个网球的代码:

  vec3 getBackgroundColor(vec2 uv) {
  uv = uv * 0.5 + 0.5; // remap uv from <-0.5,0.5> to <0.25,0.75>
  vec3 gradientStartColor = vec3(1., 0., 1.);
  vec3 gradientEndColor = vec3(0., 1., 1.);
  return mix(gradientStartColor, gradientEndColor, uv.y); // gradient goes from bottom to top
}

float sdCircle(vec2 uv, float r, vec2 offset) {
  float x = uv.x - offset.x;
  float y = uv.y - offset.y;

  return length(vec2(x, y)) - r;
}

float dot2( in vec2 v ) { return dot(v,v); }

float sdBezier( in vec2 pos, in vec2 A, in vec2 B, in vec2 C )
{
    vec2 a = B - A;
    vec2 b = A - 2.0*B + C;
    vec2 c = a * 2.0;
    vec2 d = A - pos;
    float kk = 1.0/dot(b,b);
    float kx = kk * dot(a,b);
    float ky = kk * (2.0*dot(a,a)+dot(d,b)) / 3.0;
    float kz = kk * dot(d,a);
    float res = 0.0;
    float p = ky - kx*kx;
    float p3 = p*p*p;
    float q = kx*(2.0*kx*kx-3.0*ky) + kz;
    float h = q*q + 4.0*p3;
    if( h >= 0.0)
    {
        h = sqrt(h);
        vec2 x = (vec2(h,-h)-q)/2.0;
        vec2 uv = sign(x)*pow(abs(x), vec2(1.0/3.0));
        float t = clamp( uv.x+uv.y-kx, 0.0, 1.0 );
        res = dot2(d + (c + b*t)*t);
    }
    else
    {
        float z = sqrt(-p);
        float v = acos( q/(p*z*2.0) ) / 3.0;
        float m = cos(v);
        float n = sin(v)*1.732050808;
        vec3  t = clamp(vec3(m+m,-n-m,n-m)*z-kx,0.0,1.0);
        res = min( dot2(d+(c+b*t.x)*t.x),
                   dot2(d+(c+b*t.y)*t.y) );
        // the third root cannot be the closest
        // res = min(res,dot2(d+(c+b*t.z)*t.z));
    }
    return sqrt( res );
}

vec3 drawScene(vec2 uv) {
  vec3 col = getBackgroundColor(uv);
  float d1 = sdCircle(uv, 0.2, vec2(0., 0.));
  vec2 A = vec2(-0.2, 0.2);
  vec2 B = vec2(0, 0);
  vec2 C = vec2(0.2, 0.2);
  float d2 = sdBezier(uv, A, B, C) - 0.03;
  float d3 = sdBezier(uv*vec2(1,-1), A, B, C) - 0.03;

  float res; // result
  res = max(d1, -d2); // subtraction - subtract d2 from d1
  res = max(res, -d3); // subtraction - subtract d3 from the result

  res = smoothstep(0., 0.01, res); // antialias entire result

  col = mix(vec3(.8,.9,.2), col, res);
  return col;
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
  vec2 uv = fragCoord/iResolution.xy; // <0, 1>
  uv -= 0.5; // <-0.5,0.5>
  uv.x *= iResolution.x/iResolution.y; // fix aspect ratio

  vec3 col = drawScene(uv);

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

总结

本节教程中,我们用着色器绘制了一颗心型️ 以及其他的形状。我们学会了绘制星星,线段和贝塞尔曲线。当然,绘制这些2D形状完全是根据我个人的偏好来的。其实还有许许多多的形状需要你自己去绘制。我们同时也学会了如何将基础图形结合在一起创建更多复杂的图形。在下一篇文章中,我们将会运用光线步进函数绘制3D图形和场景(raymarching!);

参考资源

手机扫一扫

移动阅读更方便

阿里云服务器
腾讯云服务器
七牛云服务器