Shadertoy 教程 Part 5 – 運用SDF繪製出更多的2D圖形

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!)🎉;

參考資源