7. 変換とパターン

前章では全ての図形が原点にあり、別の場所に置く時は p から位置を引きましたが、其の理由はちゃんと説明しませんでした。
此の章では其れを説明し、更に先に進みます。
図形を移動・回転・拡大縮小し、時間でアニメーションさせ、1行でも何百回も繰り返します。
グリッド、市松模様、レンガの壁、鏡像、放射状のパターンです。

其の全ての裏にあるのは、線型代数シリーズの一つの考え方です。
フラグメントシェーダーは図形を動かさず、代わりに座標を動かします。
そして、座標を動かすのは図形を動かす事の逆です。
章の最後では、全てを組み合わせて、レンガの壁に掛かった動く掛け時計を作ります。

第6章と同じく、変更するのは shader/playground.glsl だけです。
第6章の関数(sdCircle、sdBox、sdSegment、sdRoundBox、fill、smin、paint)は毎回繰り返さずに使います。

7.1 図形を動かす事は座標を動かす事

フラグメントシェーダーが実際に何をしているか考えてみましょう。
ピクセル毎に1回実行され、ピクセルの位置𝐩を受け取り、距離関数に「此の点は図形からどれ位離れているか?」と聞きます。
sdCircle(p, r) が知っている円は一つだけ、原点を中心とする円です。

では、中心が𝐜の円が欲しいとします。
𝐩にあるピクセルにとって「𝐜にある円からどれ位離れているか?」という質問は、「𝐩−𝐜は原点にある円からどれ位離れているか?」と同じ質問です。
ピクセルの位置を図形自身の中心から見た位置で表し、其れを元の関数に聞くのです:

fmoved(𝐩)=f(𝐩−𝐜)

原点の円(破線)と、cだけ動かした同じ円。動かした円の近くのピクセルpは、元の円の近くの点p − cに対応する

つまり、図形を+𝐜動かすには、座標を−𝐜動かします。
此れが一般的な規則です。
図形に適用したい変換が𝐌なら、距離関数を𝐌−1𝐩で評価します。
逆変換を使うのです(線型代数の第10章)。
「𝐜だけ動かす」の逆は「−𝐜だけ動かす」で、其の為に第6章では何処でも p - c と書いていました。

向きがはっきり分かる様に、2つの箱で作ったL字形を使います。
此れなら上下左右が区別出来ます:

// L字形(向きが分かる様に)
float sdL(vec2 p) {
  return min(sdBox(p - vec2(0.0, 0.1), vec2(0.05, 0.25)),
             sdBox(p - vec2(0.15, -0.1), vec2(0.2, 0.05)));
}

void main() {
  vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;
  vec3 color = vec3(0.10, 0.10, 0.12);

  vec2 c = vec2(0.6, 0.3);              // 図形を動かしたい場所
  float d0 = sdL(p);                    // 元の位置
  float d = sdL(p - c);                 // 座標から c を引く → 図形は +c に動く

  color = mix(color, vec3(0.45), outline(d0));
  color = mix(color, vec3(1.00, 0.75, 0.20), fill(d));
  out_color = vec4(color, 1.0);
}

outline は第6章6.9のヘルパーです:1.0 - smoothstep(0.0, 1.5 * fwidth(d), abs(d))。

原点にあるLの灰色の輪郭と、右上に動いた黄色いL

c を時間に依存させると図形が動きます。
vec2 c = 0.5 * vec2(cos(uTime), sin(uTime)); なら、2π秒毎に半径0.5の円を1周します。

7.2 回転

線型代数の第8章の回転行列は、点を角度θだけ反時計回りに回します:

𝐑(θ)=[cosθ−sinθsinθcosθ]

7.2.1 mat2を作る

GLSLには2×2行列の型 mat2 があります。
其のコンストラクタは4つの数を行毎ではなく列毎に受け取ります(第2章で見た通り、GLSLの行列は列優先です)。
𝐑の1列目は(cosθ,sinθ)、2列目は(−sinθ,cosθ)なので:

// 回転行列(角度aはラジアン、反時計回り)。mat2は列毎に値を並べる
mat2 rot(float a) {
  float c = cos(a), s = sin(a);
  return mat2(c, s, -s, c);  // 1列目 (c, s)、2列目 (-s, c)
}

紙に書いた見た目通りに mat2(c, -s, s, c) と書くと転置になり、逆向きに回ります。
此れはGLSLで最もよくあるバグの一つで、結果がやはり回転なので気付き難いです。

M * v は本物の行列・ベクトル積です(線型代数の第6章)。
θ=90°(π2ラジアン)、つまりcosθ=0、sinθ=1で、(1,0)に適用して確かめましょう:

𝐑(π2)[10]=[0−110][10]=1[01]+0[−10]=[01]

x軸上の点がy軸に向きます。
反時計回りに4分の1回転です。
コードでは、rot(1.5708) * vec2(1.0, 0.0) は(丸め誤差を除いて)vec2(0.0, 1.0) になります。

7.2.2 座標を回すと図形は逆に回る

ここでも逆の規則です。
rot(a) * p は座標を反時計回りに回すので、図形は時計回りに回ります。
図形を a だけ反時計回りに回すには、逆回転の rot(-a) を使います:

  float a = 0.5236;                     // 30°
  vec2 pl = p - vec2(-0.6, 0.0);
  vec2 pr = p - vec2(0.6, 0.0);
  float d0 = min(sdL(pl), sdL(pr));     // 回転前
  float d1 = sdL(rot(a) * pl);          // 左:rot(a)を座標に → 時計回り
  float d2 = sdL(rot(-a) * pr);         // 右:rot(-a)を座標に → 反時計回り

回転前の輪郭の上に2つのL字形。左の青は時計回り、右の黄色は反時計回りに回っている

シェーダーのコードでは p * rot(a) もよく見掛けます。
GLSLでは、行列の左にあるベクトルは行ベクトルとして扱われ、v * M は transpose(M) * v と同じです。
回転では転置が逆行列なので(線型代数の第10章)、p * rot(a) は rot(-a) * p と同じです。
どちらでも構いませんが、あたしは意味が分かり易い rot(-a) * p と書きます。

回転は常に、与えた座標の原点の周りで回ります。
図形を其の中心の周りで回すには、先ず図形が自分の座標の原点に来ていなければなりません。
其処で変換の組み合わせ(7.4)が必要になります。

7.3 拡大縮小

図形をs倍に大きくするには、逆の規則に従って座標をsで割ります。
但し、回転や移動には無かった落とし穴があります。

半径0.2の円をs=2倍にすると、半径0.4の円になる筈です。
点𝐩=(0.6,0)では:

f(𝐩s)=‖(0.3,0)‖−0.2=0.1本当の距離=‖(0.6,0)‖−0.4=0.2

境界は正しい場所にありますが、距離は小さい図形の単位で測られています。
拡大した世界では全ての距離がs倍になるので、掛けて戻します:

fscaled(𝐩)=f(𝐩s)⋅s

問題になるのでしょうか?
fill ヘルパーで普通に塗るだけなら、殆どなりません。
fwidth が d の1ピクセル毎の本当の変化を測るので、どちらでも境界は1ピクセル幅のままです。
然し、距離を長さとして使う物は全て壊れます。
輪郭、角丸、光彩、影です。
拡大した円に太さ0.04のリング(abs(d) - 0.02)を描き、補正無しと補正有りを比べてみましょう:

  float s = 2.0;
  vec2 pl = p - vec2(-0.65, 0.0);
  vec2 pr = p - vec2(0.65, 0.0);

  float dl = abs(sdCircle(pl / s, 0.2)) - 0.02;      // 左:距離を直さない → 線も2倍太い
  float dr = abs(sdCircle(pr / s, 0.2) * s) - 0.02;  // 右:距離に s を掛けて直す

左:補正しないリングは2倍太い。右:補正したリングは意図した太さ

非一様な拡大縮小(xとyで違う倍率、p / vec2(sx, sy))は円を楕円にしますが、掛けて戻す一つの倍率がありません。
方向によって距離の伸び方が違うからです。
min(sx, sy) を掛けると決して大き過ぎない距離になり、2Dの描画なら大抵其れで十分です。
楕円の正確な距離は意外と難しいので、殆どの用途では近似で問題ありません。

7.4 変換の組み合わせ:順番が大事

線型代数では変換を𝐌=𝐓𝐑の様な積で書きました。
先に回転し、次に平行移動します(第7章と第9章)。
図形を描くには其の逆が必要で、積の逆は逆行列の積を逆の順番にした物です:

(𝐓𝐑)−1=𝐑−1𝐓−1

右から左に読みます。
先ず平行移動を元に戻し(𝐜を引く)、次に回転を元に戻します(−aだけ回す)。
コードでは rot(-a) * (p - c) です。
図形は𝐜の周りでその場で回ります。

逆の順番𝐌=𝐑𝐓(先に図形を原点から離し、次に全体を原点の周りで回す)の逆は𝐓−1𝐑−1、つまり rot(-a) * p - c です。
今度は図形が、軌道上の惑星の様に原点の周りを円を描いて回ります。

次のシェーダーは両方を45°ずつ8回描き、後の物程明るくします:

  vec2 c = vec2(0.35, 0.0);
  vec2 pl = p - vec2(-0.75, 0.0);
  vec2 pr = p - vec2(0.6, 0.0);

  // 8つの角度で描き、新しい物程明るくする
  for (int i = 0; i < 8; i++) {
    float a = float(i) * 0.7854;              // 45°ずつ
    float k = 0.3 + 0.7 * float(i) / 7.0;
    float dl = sdL(rot(-a) * (pl - c) * 2.0) / 2.0;   // 左:回転してから移動 → その場で回る
    float dr = sdL((rot(-a) * pr - c) * 2.0) / 2.0;   // 右:移動してから回転 → 原点の周りを回る
    color = mix(color, vec3(0.50, 0.75, 1.00) * k, fill(dl));
    color = mix(color, vec3(1.00, 0.75, 0.20) * k, fill(dr));
  }
  // 回転の中心
  color = mix(color, vec3(1.0), fill(min(sdCircle(pl - c, 0.02), sdCircle(pr, 0.02))));

左:1点の周りで回る8つの青いL。右:中心の周りに円形に並んだ8つの黄色いL

* 2.0 と / 2.0 は、7.3の規則でs=0.5とした半分への縮小です。
座標を0.5で割り(2を掛け)、距離に0.5を掛けます(2で割ります)。
float(i) はループカウンタを float に変換します。
デスクトップのGLSLなら自動で変換されますが(第2章)、変換を書いておけば、決して自動変換しないGLSL ESでもそのまま使えます。

float(i) * 0.7854 を uTime に替えてループを取り除くと、回る図形と公転する図形になります。
一般に、動きを言葉で表す時(「拡大して、回して、其処に動かす」)、コードは逆の手順を逆の順番で適用します。
rot(-a) * (p - c) / s として、最後に距離に s を掛けます。

7.5 繰り返し:floorとfract

ここからが座標の技の本当に強力な所です。
図形を何度も描く代わりに、画面の全ての部分が「自分は原点の近くにいる」と思う様に座標を折り畳みます。

7.5.1 floorとfract

  • floor(x):xより大きくない最大の整数。floor(2.7) = 2、floor(-0.3) = −1。
  • fract(x):小数点以下の部分、x−⌊x⌋。常に[0,1)の範囲です。fract(2.7) = 0.7、fract(-0.3) = 0.7(−0.3−(−1)=0.7だから)。

左:floor(x)は階段。右:fract(x)は0から1まで上がり、整数毎に戻るノコギリ波

どちらもベクトルに対して成分毎に動作します。
従って、点 q に対して floor(q) は其の点が入っているグリッドのマスの番号、fract(q) はマスの中での位置です。

7.5.2 図形のグリッド

1単位にnマス入る様に座標をn倍し、マスの番号とマスの中の位置に分け、マスの中の位置を中央に寄せます(0.5を引くと−0.5〜0.5になります):

  float n = 4.0;              // 1単位に4マス(画面の縦は8マス)
  vec2 q = p * n;             // 1マス = 1単位
  vec2 id = floor(q);         // マスの番号(整数)
  vec2 f = fract(q) - 0.5;    // マスの中の座標(-0.5〜0.5)

𝐩=(0.3,−0.2)での計算例:

𝐪=4𝐩=(1.2,−0.8)id=(⌊1.2⌋,⌊−0.8⌋)=(1,−1)𝐟=(0.2,0.2)−0.5=(−0.3,−0.3)

此の点はマス(1, −1)の、左下の角寄りにあります。
全てのマスに中心が原点の小さな座標系が出来たので、sdCircle(f, r) で全てのマスに一度に円が描けます。
距離はマスの単位なので、拡大縮小の規則に従ってnで割り、p の単位に戻します。

マスの番号 id はマスの中の全てのピクセルで同じなので、其れを使ってマス毎に色と大きさを変えられます:

// fwidthを使わない塗り(fractの継ぎ目では隣のピクセルとの差が意味を持たない為)
float fillPx(float d) {
  float px = 2.0 / uResolution.y;
  return 1.0 - smoothstep(-px, px, d);
}

void main() {
  vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;
  vec3 color = vec3(0.10, 0.10, 0.12);

  float n = 4.0;              // 1単位に4マス(画面の縦は8マス)
  vec2 q = p * n;             // 1マス = 1単位
  vec2 id = floor(q);         // マスの番号(整数)
  vec2 f = fract(q) - 0.5;    // マスの中の座標(-0.5〜0.5)

  // マスの番号で色と大きさを変える
  vec3 cellColor = 0.5 + 0.5 * cos(6.2832 * (id.x * 0.13 + id.y * 0.29) + vec3(0.0, 2.0, 4.0));
  float r = 0.25 + 0.15 * sin(id.x * 1.7 + id.y * 2.3);
  float d = sdCircle(f, r) / n;   // マスの単位からpの単位に戻す

  color = mix(color, cellColor, fillPx(d));
  // マスの境界線
  vec2 g = abs(f);
  float grid = 0.5 - max(g.x, g.y);
  color = mix(color, vec3(0.3), fillPx(abs(grid / n) - 0.002) * 0.6);
  out_color = vec4(color, 1.0);
}

様々な色と大きさの円のグリッド。マスの間に薄い線

色の式 0.5 + 0.5 * cos(6.2832 * t + vec3(0.0, 2.0, 4.0)) は第5章のプレイグラウンドと同じコサインのパレットです。
t が変わると赤・緑・青のコサインがずれて変わるので、虹の色を巡ります。
id.x と id.y を違う係数で混ぜると、マス毎に違う t になります。
乱数ではありませんが(本当の乱数は第8章)、面白く見える程度には変化があります。

グリッドの線は第6章の箱の技を使っています。
max(g.x, g.y) は「箱」の意味でマスの中心からどれ位離れているかなので、0.5 - max(g.x, g.y) はマスの境界までの距離です。

7.5.3 落とし穴:継ぎ目でのfwidth

何故第6章の fill ではなく fillPx なのでしょうか?
fwidth は値を隣のピクセルと比べます。
二つのマスの境界では、fract が殆ど1から0に跳び、id が変わります。
此の例では円の半径が id に依存するので、距離がマスからマスへ跳び、fwidth が巨大な変化を報告し、fill が境界を何ピクセルにも渡ってぼかします。
結果は、マスの境界に沿った薄い色の線です。
値が跳ぶ所(fract、floor、mod の継ぎ目)では、fillPx が 2.0 / uResolution.y でしている様に、ピクセルの大きさを直接測って下さい。
此れが使えるのは d を p の単位に戻しているからです。
常にnで割るべき理由がもう一つ増えました。

繰り返しにはもう一つ制限があります。
各ピクセルは自分のマスしか知りません。
マスからはみ出た図形は境界で切れます。
隣のマスが其れを描かないからです。
図形はマスの中に収めるか、隣のマスも調べて下さい(第8章のボロノイノイズで其れをします)。

時間を使うとパターンがずっと生き生きします。
次の例では、各マスの角丸の四角が rot(-(uTime * 1.5 + phase)) * f で其々の位相で回り、呼吸する様に大きさを変えます(phase = id.x * 0.5 + id.y * 0.9):

様々な色の角丸の四角のグリッド。其々違う角度で回っている

7.6 modと市松模様

mod(x, y) はxをyで割った余りで、次の様に定義されています:

mod(x,y)=x−y⌊x/y⌋

正の数なら普通の余りです。
mod(7.0, 3.0) = 7−3×2=1。
負の数ではCの fmod と違います。
GLSLの mod(-1.0, 2.0) は−1−2⌊−0.5⌋=−1−2×(−1)=1で、常に0とyの間ですが、Cの fmod(-1.0, 2.0) は−1です。
パターンにはGLSLの振る舞いが丁度良いです。
パターンが負の座標へ継ぎ目無く続くからです。

市松模様はマスの番号の偶奇です。
id.x + id.y が偶数なら暗いマス、奇数なら明るいマスです。

void main() {
  vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;

  vec2 id = floor(p * 4.0);
  float checker = mod(id.x + id.y, 2.0);   // 0, 1, 0, 1, ...
  vec3 color = mix(vec3(0.15, 0.15, 0.18), vec3(0.85, 0.80, 0.70), checker);
  out_color = vec4(color, 1.0);
}

ウィンドウ全体を覆う暗いマスと明るいマスの市松模様

座標の符号が変わるウィンドウの中央でも、パターンが滑らかに続いている事に注目して下さい。
Cの fmod だと、中央より左と下のマスの色が間違ってしまいます。

7.7 レンガ

レンガの壁は、1段おきにレンガ半分ずれたグリッドです。
先に段を求め、奇数段のxをずらしてから、マスに分けます:

void main() {
  vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;

  vec2 size = vec2(0.4, 0.2);          // レンガ1つの大きさ
  vec2 q = p / size;                   // 1レンガ = 1単位
  float row = floor(q.y);
  q.x += 0.5 * mod(row, 2.0);          // 1段おきに半分ずらす
  vec2 id = floor(q);
  vec2 f = (fract(q) - 0.5) * size;    // レンガの中の座標(pの単位)

  float d = sdRoundBox(f, size * 0.5 - 0.012, 0.02);         // 目地の分だけ小さく
  float shade = 0.85 + 0.15 * cos(id.x * 2.7 + id.y * 5.1);  // レンガ毎の明るさ
  vec3 brick = vec3(0.70, 0.32, 0.22) * shade;
  vec3 mortar = vec3(0.55, 0.53, 0.50);
  vec3 color = mix(mortar, brick, fill(d));
  out_color = vec4(color, 1.0);
}

灰色の目地の赤いレンガの壁。1段おきにレンガ半分ずれている

ここではマスが正方形ではないので、後で距離を割る代わりに、マスの中の位置に size を掛けて戻し、p の単位で直接レンガを描いています。
各レンガはマスより少し小さい角丸の箱で、隙間が目地です。
全てのレンガが同じ大きさなので距離は継ぎ目でも連続しており、ここでは普通の fill が使えます。

7.8 absで鏡映対称

abs は空間を折り畳みます。
vec2(abs(p.x), p.y) は画面の左半分を右半分に写すので、右に描いた物が左に鏡映されて現れます。
abs(p) は両方の軸を折り畳み、4つのコピーになります。
此れは既に sdBox の中で使いました。
4分の1に折り畳んだお陰で、角を一つだけ扱えば良かったのです。

float sdPetal(vec2 p) {
  float d = sdSegment(p, vec2(0.15, 0.1), vec2(0.6, 0.5)) - 0.07;
  return min(d, sdCircle(p - vec2(0.75, 0.2), 0.12));
}

void main() {
  vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;
  vec3 color = vec3(0.10, 0.10, 0.12);
  float d0 = sdPetal(p);                       // 元の図形
  float d = sdPetal(vec2(abs(p.x), p.y));      // xを鏡映
  color = mix(color, vec3(1.00, 0.75, 0.20), fill(d));
  color = mix(color, vec3(0.95, 0.35, 0.35), fill(abs(d0) - 0.006));
  out_color = vec4(color, 1.0);
}

左:xで鏡映したカプセルと点。右:両方の軸にabsを使った4つのコピー。赤い輪郭が元の図形

左の画像は vec2(abs(p.x), p.y)、右は abs(p) です。
顔、ロゴ、雪の結晶、装飾は対称性に満ちていて、折り畳む度に作業が半分になります。

7.9 極座標

グリッドはxとyに沿って繰り返します。
中心の周りに繰り返す物(花、車輪、時計の文字盤、レーダー画面)では、代わりに中心からの距離と角度で点を表します:

r=‖𝐩‖,θ=atan(py,px)

点pの原点からの距離rと、x軸から測った角度θ。x軸より下の点qの角度は負

GLSLの atan には2つの形があります。
引数2つの atan(y, x)(Cでは atan2)は、正のx軸から反時計回りに測った−πからπまでの完全な角度を返します。
未定義なのは両方の引数が0の時だけです。
引数1つの atan(t) は−π2からπ2までの角度を返し、反対の方向を区別出来ないので、極座標には必ず引数2つの形を使って下さい。

角度を色に、半径を縞にして表示してみます:

void main() {
  vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;

  float r = length(p);            // 中心からの距離
  float a = atan(p.y, p.x);       // 角度(-π〜π)

  vec3 color = 0.5 + 0.5 * cos(a + vec3(0.0, 2.0, 4.0));   // 角度で色相
  color *= 0.75 + 0.25 * cos(62.83 * r);                  // 半径0.1毎の縞
  out_color = vec4(color, 1.0);
}

中心の周りを巡る虹色と、同心の暗いリング

中心の左側で角度はπから−πに跳びますが、其処で色に継ぎ目はありません。
コサインの周期が2πだからです。
此れは覚えておいて下さい。
θから計算する物は全て周期2πであるべきで、そうでないと負のx軸に沿って継ぎ目が出ます。

7.9.1 花

円の半径は一定です。
半径を角度に依存させると、円から花びらが生えます:

d=r−(r0+Acos(nθ))

n=5なら、半径が円を1周する間に5回上下します。
整数nならcos(nθ)は周期2πなので、継ぎ目はありません。

// 花:半径が角度で変わる円(正確な距離ではないので、小さめの係数を掛ける)
float sdFlower(vec2 p, float petals, float r, float amp) {
  float a = atan(p.y, p.x);
  return (length(p) - (r + amp * cos(petals * a))) * 0.5;
}

此れは正確な距離ではありません。
花びらの間では本当の距離より速く値が変わり、此の大きさでは約2倍速くなります。
0.5を掛けると大体正しい尺度に戻るので、輪郭や光彩が大体正しい幅になります(第14章のレイマーチングでは、大き過ぎる距離はレイの行き過ぎの原因になります)。
fill には関係ありません。
fwidth が関数の傾きを其のまま測るからです。

7.9.2 角度の繰り返し

fract は直線に沿って繰り返します。
円の周りに繰り返すには、1周をn個の等しい区画に分け、点がどの区画にあるかを求め、最初の区画に回して戻します:

中心の周りの12の区画。区画k = 2にある点pは、k × 2π/12だけ回して戻され、区画0の目盛りの隣のqになる

sector=2πn,k=⌊θsector+12⌋,𝐪=𝐑(−k⋅sector)𝐩

floor の前に12を足すと一番近い区画に丸められるので、区画0はx軸から始まるのではなく、x軸が中心になります。
n=12(30°の区画)で67°にある点での計算例:6730+0.5=2.73なのでk=2となり、−60°回すと点は7°、最初の目盛りの直ぐ隣に来ます。
全てのピクセルが区画0の近くに来るので、x軸上の sdBox 1つで12個の目盛り全てが描けます:

  // 右:角度の繰り返しで12個の目盛り
  vec2 q = p - vec2(0.65, 0.0);
  float sector = 6.2832 / 12.0;                       // 1区画の角度(30°)
  float a = atan(q.y, q.x);
  float k = floor(a / sector + 0.5);                  // 一番近い区画の番号
  vec2 r = rot(-k * sector) * q;                      // 区画の中心をx軸に回す
  float tick = sdBox(r - vec2(0.45, 0.0), vec2(0.07, 0.02));
  color = mix(color, vec3(1.00, 0.75, 0.20), fill(tick));

左:花びら3枚、5枚、8枚のピンクの花。右:円形に並んだ12個の黄色い目盛り

左側の花は sdFlower(p - centre, 3.0, 0.24, 0.1)、(5.0, 0.25, 0.08)、(8.0, 0.3, 0.06) で描いています。

7.10 全部まとめる:掛け時計

此の章の最終的なプレイグラウンドは、テスターを起動してからの本物の秒数を表示する時計です。
時計の広告でいつも見る10時10分から始まります。
此の章の殆ど全てを使います。
壁にはレンガ、60 + 12個の目盛りには角度の繰り返し、針には回転、そして角度を小さく保つ為に fract です。
以下が playground.glsl の新しい部分です。
其の上の第6章の関数はそのままで、キャラクターの sdHead だけを削除しています。

// ---- 第7章:座標の変換と繰り返し ----

// 回転行列(角度aはラジアン、反時計回り)。mat2は列毎に値を並べる
mat2 rot(float a) {
  float c = cos(a), s = sin(a);
  return mat2(c, s, -s, c);  // 1列目 (c, s)、2列目 (-s, c)
}

// fwidthを使わない塗り(fractの継ぎ目では隣のピクセルとの差が意味を持たない為)
float fillPx(float d) {
  float px = 2.0 / uResolution.y;
  return 1.0 - smoothstep(-px, px, d);
}

// レンガの壁(第7章のタイル)
vec3 bricks(vec2 p) {
  vec2 size = vec2(0.4, 0.2);
  vec2 q = p / size;
  q.x += 0.5 * mod(floor(q.y), 2.0);   // 1段おきに半分ずらす
  vec2 id = floor(q);
  vec2 f = (fract(q) - 0.5) * size;
  float d = sdRoundBox(f, size * 0.5 - 0.012, 0.02);
  float shade = 0.85 + 0.15 * cos(id.x * 2.7 + id.y * 5.1);
  return mix(vec3(0.55, 0.53, 0.50), vec3(0.70, 0.32, 0.22) * shade, fillPx(d));
}

// 角度の繰り返し:pを一番近い区画の中心(x軸上)に回す
vec2 repeatAngle(vec2 p, float count) {
  float sector = 6.2832 / count;
  float k = floor(atan(p.y, p.x) / sector + 0.5);
  return rot(-k * sector) * p;
}

// 針:12時の方向を向いたカプセルを、時計回りに回す
// 座標をrot(+a)で回すと、図形は-a、つまり時計回りに回る
float sdHand(vec2 p, float turn, float back, float len, float width) {
  vec2 q = rot(turn * 6.2832) * p;
  return sdSegment(q, vec2(0.0, -back), vec2(0.0, len)) - width;
}

// 時針と分針を1つの距離に(影にも使う)
float sdHands(vec2 p, float t) {
  float h = sdHand(p, fract(t / 43200.0), 0.08, 0.36, 0.028);   // 時針:12時間で1周
  float m = sdHand(p, fract(t / 3600.0), 0.10, 0.54, 0.018);    // 分針:1時間で1周
  return min(h, m);
}

void main() {
  // 中心が原点、縦が-1〜1になる座標(第5章)
  vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;

  // 10時10分0秒から始まる時刻(秒)
  float t = uTime + 10.0 * 3600.0 + 10.0 * 60.0;

  // 背景:少し暗くしたレンガの壁
  vec3 color = bricks(p) * 0.55;

  // 時計の影
  float shadow = sdCircle(p - vec2(0.04, -0.06), 0.8);
  color *= 1.0 - 0.6 * (1.0 - smoothstep(-0.05, 0.1, shadow));

  // 枠と文字盤
  float rim = sdCircle(p, 0.8);
  float face = sdCircle(p, 0.72);
  color = paint(color, vec3(0.20, 0.18, 0.22), rim);
  color = paint(color, vec3(0.96, 0.93, 0.85), face);

  // 目盛り:60個の小さい物と12個の大きい物
  float minuteTick = sdBox(repeatAngle(p, 60.0) - vec2(0.65, 0.0), vec2(0.025, 0.004));
  float hourTick = sdBox(repeatAngle(p, 12.0) - vec2(0.62, 0.0), vec2(0.06, 0.014));
  color = paint(color, vec3(0.25), minuteTick);
  color = paint(color, vec3(0.12), hourTick);

  // 針の影、時針と分針
  color *= 1.0 - 0.3 * (1.0 - smoothstep(-0.02, 0.03, sdHands(p - vec2(0.015, -0.025), t)));
  color = paint(color, vec3(0.12, 0.12, 0.15), sdHands(p, t));

  // 秒針:1秒毎にカチッと動く
  float second = sdHand(p, fract(floor(t) / 60.0), 0.15, 0.6, 0.006);
  second = min(second, sdCircle(p, 0.035));
  color = paint(color, vec3(0.85, 0.15, 0.15), second);

  out_color = vec4(color, 1.0);
}

レンガの壁に掛かった、暗い枠とクリーム色の文字盤の丸い掛け時計。10時10分を指し、赤い秒針は12の位置

面白い所:

  • 時計回りの針。 此方の回転は反時計回りですが、時計は逆に回ります。逆の規則がタダで解決してくれます。座標に rot(+a) を使うと図形は−a、つまり時計回りに回ります。各針は上(12時)を向いたカプセルで、turn は1周の割合です。0.25は3時、0.5は6時です。
  • 割合。 時針は12時間 = 43200秒で1周するので割合はt/43200、分針は3600秒で1周します。開始時はt=36600で、36600/43200=0.847周は文字盤の10.17時、36600/3600=10.16‾の小数部分0.16‾周は10分です。10時10分です。
  • 何故fractか。 float の有効桁数は約7桁です。巨大な角度の cos と sin は精度を失い、テスターをしばらく動かしていると針が震え始めます。fract は1周の割合を0〜1に保つので、角度は決して2πを超えません。精度はシェーダーで繰り返し出て来る問題で、第8章でもう一度痛い目に遭います。
  • カチッと動く秒針。 floor(t) は1秒に1回しか変わらないので、針は本物のクォーツ時計の様に跳びます。floor を外すと滑らかに動く針になります。
  • 影。 第6章のキャラクターと同じです。同じ距離をずらし、広い smoothstep でぼかします。
  • 目盛り。 repeatAngle(p, 60.0) と repeatAngle(p, 12.0) で、60個と12個の箱が其々 sdBox 1回の呼び出しになります。区画の境界では両側の目盛りまでの距離が同じなので距離は連続しており、fill が使えます。

7.11 此の章の新しいGLSLの機能

機能 何をするか
mat2(a, b, c, d) 1列目が(a, b)、2列目が(c, d)の2×2行列
M * v 行列と列ベクトルの積
v * M 行ベクトルと行列の積、transpose(M) * v と同じ
floor(x) xより大きくない最大の整数
fract(x) x−⌊x⌋、常に[0, 1)
mod(x, y) x−y⌊x/y⌋、負のxでも0とyの間
atan(y, x) 点(x, y)の角度、−πからπ
atan(t) tのアークタンジェント、−π/2からπ/2
float(i) intをfloatに変換

7.12 実際のゲームやエンジンでの使い方

  • テクスチャのタイリングとスクロール。 fract の技は、テクスチャのハードウェアが GL_REPEAT ラップモード(第11章)でしている事其の物です。床のタイル、草のテクスチャ、レンガのマテリアルはテクスチャ座標を掛け算して繰り返します。其処に uTime を足すとテクスチャがスクロールし、多くのゲームはベルトコンベア、滝、溶岩を此れでアニメーションさせています。
  • プロシージャルマテリアル。 マテリアルエディタ(Unrealのマテリアルグラフ、Blenderのシェーダーノード、Substance Designer)のレンガ、タイル、板、市松模様は、此れと同じノードで作られています。floor、fract、mod、マス毎の図形、そして変化を付ける為のマスの番号です。
  • 円形のUI。 アビリティアイコンのクールダウン表示(MMOやMOBAの回転する影)、円形の体力・スタミナゲージ、ラジアルメニュー、ローディングスピナーは atan(y, x) と塗りの割合を比べています。ミニマップは正に此の rot で座標を回しています。
  • スプライトとパーティクル。 2Dエンジンは同じ2×2行列でスプライトを回転・拡大縮小し、大抵は頂点シェーダー(第13章)で行います。パーティクルシステムはパーティクル毎の角度で各パーティクルを回します。
  • 万華鏡と対称性。 鏡映と角度の繰り返しは、万華鏡のポストエフェクト、RPGの魔法陣や呪文のエフェクトに使われます。
  • 3D。 レイマーチング(第14章)では、3D座標に mod を使うと、1つ分のコストで物体を全方向に無限に繰り返せます。無限の廊下や柱の森を作る、デモシーンの古典的な技です。

7.13 まとめ

  • シェーダーは図形ではなく座標を動かします。図形を𝐌で変換するには、距離を𝐌−1𝐩で評価します。
  • 平行移動:p - c。回転:mat2(c, s, -s, c)(列毎!)で rot(-a) * p。拡大縮小:f(p / s) * s。
  • 変換の組み合わせでは逆の手順を逆の順番で適用します。rot(-a) * (p - c) はその場で回り、rot(-a) * p - c は公転します。
  • floor はマスの番号、fract はマスの中の位置を返します。番号はマス毎の変化に使います。距離は倍率で割ります。
  • 値が跳ぶ継ぎ目では fwidth が失敗します。其処ではピクセルの大きさ 2.0 / uResolution.y を使います。
  • mod で市松模様や交互の段が作れます。Cの fmod と違い、割る数が正なら決して負になりません。
  • abs は鏡映、atan(y, x) と length は極座標、一番近い区画への回転で図形を円形に繰り返せます。
  • 精度を失わない様に、角度は fract で小さく保ちます。

7.14 課題

  1. ★ x=4.5、x=−0.25、x=−3.5について floor、fract、mod(x, 3.0) を計算して下さい。次に、n=5で𝐩=(−0.1,0.7)のマスの番号と、中央に寄せた位置 fract(q) - 0.5 を計算して下さい。
  2. ★ 行列 mat2(0.0, -1.0, 1.0, 0.0) を行と列で書き出し、(1,0)と(0,1)に掛けて下さい。どんな回転ですか?座標に適用すると、図形は時計回りと反時計回りのどちらに回りますか?
  3. ★★ L字形を、4秒で中心の周りを1周し、同時に其の倍の速さで自分の角の周りを回る様にアニメーションさせて下さい。rot 2回と引き算1回を正しい順番で使う筈です。2秒後にLが中心の反対側にあり、丁度1回転している事を確かめて下さい。
  4. ★★ ハニカムの様な模様を描いて下さい。1段おきにマス半分ずれ(レンガの様に)、段の間隔が32マスの円のグリッドです。円は6つの隣の円と接する筈です。
  5. ★★ ローディングスピナーを作って下さい。角度の繰り返しで円上に8個の点を置き、各点の明るさを区画の番号 k と uTime に依存させて、明るい点が尾を引きながら1秒で1周する様にします。
  6. ★★ 12時、3時、6時、9時の目盛りを2倍の長さの赤にして下さい。repeatAngle を、区画の番号 k も out float 引数(第2章)で返す様に変え、mod(k, 3.0) で其の4つの区画を選びます。注意:atan では区画0は12時ではなく3時にあります。
  7. ★★★ 一部の駅の時計の様に、秒針を滑らかに動かしつつ、毎秒少しだけ「弾む」様にして下さい。fract(t) と smoothstep か小さな行き過ぎのカーブを使い、毎秒の最初の0.2秒で次の秒に動かします。何故針の回転には相変わらず fract(... / 60.0) を使う必要があるのか説明して下さい。

DIFFをダウンロード

ファイル: a/shader/playground.glsl | b/shader/playground.glsl

前新
@@ -45,59 +45,92 @@ float smin(float a, float b, float k) {
45 return mix(b, a, h) - k * h * (1.0 - h); 45 return mix(b, a, h) - k * h * (1.0 - h);
46 } 46 }
47   47  
48 // 頭:円に2つの耳を滑らかにくっつける    
49 float sdHead(vec2 p) {    
50 float head = sdCircle(p, 0.6);    
51 float earL = sdCircle(p - vec2(-0.45, 0.5), 0.17);    
52 float earR = sdCircle(p - vec2(0.45, 0.5), 0.17);    
53 return smin(head, min(earL, earR), 0.12);    
54 }    
55      
56 // 図形を色で塗り重ねる 48 // 図形を色で塗り重ねる
57 vec3 paint(vec3 base, vec3 ink, float d) { 49 vec3 paint(vec3 base, vec3 ink, float d) {
58 return mix(base, ink, fill(d)); 50 return mix(base, ink, fill(d));
59 } 51 }
60   52  
    53 // ---- 第7章:座標の変換と繰り返し ----
    54  
    55 // 回転行列(角度aはラジアン、反時計回り)。mat2は列毎に値を並べる
    56 mat2 rot(float a) {
    57 float c = cos(a), s = sin(a);
    58 return mat2(c, s, -s, c); // 1列目 (c, s)、2列目 (-s, c)
    59 }
    60  
    61 // fwidthを使わない塗り(fractの継ぎ目では隣のピクセルとの差が意味を持たない為)
    62 float fillPx(float d) {
    63 float px = 2.0 / uResolution.y;
    64 return 1.0 - smoothstep(-px, px, d);
    65 }
    66  
    67 // レンガの壁(第7章のタイル)
    68 vec3 bricks(vec2 p) {
    69 vec2 size = vec2(0.4, 0.2);
    70 vec2 q = p / size;
    71 q.x += 0.5 * mod(floor(q.y), 2.0); // 1段おきに半分ずらす
    72 vec2 id = floor(q);
    73 vec2 f = (fract(q) - 0.5) * size;
    74 float d = sdRoundBox(f, size * 0.5 - 0.012, 0.02);
    75 float shade = 0.85 + 0.15 * cos(id.x * 2.7 + id.y * 5.1);
    76 return mix(vec3(0.55, 0.53, 0.50), vec3(0.70, 0.32, 0.22) * shade, fillPx(d));
    77 }
    78  
    79 // 角度の繰り返し:pを一番近い区画の中心(x軸上)に回す
    80 vec2 repeatAngle(vec2 p, float count) {
    81 float sector = 6.2832 / count;
    82 float k = floor(atan(p.y, p.x) / sector + 0.5);
    83 return rot(-k * sector) * p;
    84 }
    85  
    86 // 針:12時の方向を向いたカプセルを、時計回りに回す
    87 // 座標をrot(+a)で回すと、図形は-a、つまり時計回りに回る
    88 float sdHand(vec2 p, float turn, float back, float len, float width) {
    89 vec2 q = rot(turn * 6.2832) * p;
    90 return sdSegment(q, vec2(0.0, -back), vec2(0.0, len)) - width;
    91 }
    92  
    93 // 時針と分針を1つの距離に(影にも使う)
    94 float sdHands(vec2 p, float t) {
    95 float h = sdHand(p, fract(t / 43200.0), 0.08, 0.36, 0.028); // 時針:12時間で1周
    96 float m = sdHand(p, fract(t / 3600.0), 0.10, 0.54, 0.018); // 分針:1時間で1周
    97 return min(h, m);
    98 }
    99  
61 void main() { 100 void main() {
62 // 中心が原点、縦が-1〜1になる座標(第5章) 101 // 中心が原点、縦が-1〜1になる座標(第5章)
63 vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y; 102 vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;
64 vec2 m = (2.0 * uMouse - uResolution) / uResolution.y; // マウスも同じ座標に 103  
65   104 // 10時10分0秒から始まる時刻(秒)
66 // 背景:縦のグラデーション 105 float t = uTime + 10.0 * 3600.0 + 10.0 * 60.0;
67 vec3 color = mix(vec3(0.16, 0.12, 0.22), vec3(0.08, 0.06, 0.10), p.y * 0.5 + 0.5); 106  
68   107 // 背景:少し暗くしたレンガの壁
69 // 影:同じ距離関数をずらして、ぼかして使う 108 vec3 color = bricks(p) * 0.55;
70 float shadow = sdHead(p - vec2(0.05, -0.06)); 109  
71 color *= 1.0 - 0.5 * (1.0 - smoothstep(-0.05, 0.12, shadow)); 110 // 時計の影
72   111 float shadow = sdCircle(p - vec2(0.04, -0.06), 0.8);
73 // 頭の塗りと輪郭 112 color *= 1.0 - 0.6 * (1.0 - smoothstep(-0.05, 0.1, shadow));
74 float head = sdHead(p); 113  
75 color = paint(color, vec3(1.00, 0.80, 0.30), head); 114 // 枠と文字盤
76 color = paint(color, vec3(0.35, 0.18, 0.10), abs(head) - 0.012); 115 float rim = sdCircle(p, 0.8);
77   116 float face = sdCircle(p, 0.72);
78 // ほっぺ:境界を広くぼかした円 117 color = paint(color, vec3(0.20, 0.18, 0.22), rim);
79 float cheek = min(sdCircle(p - vec2(-0.36, -0.12), 0.09), 118 color = paint(color, vec3(0.96, 0.93, 0.85), face);
80 sdCircle(p - vec2(0.36, -0.12), 0.09)); 119  
81 color = mix(color, vec3(1.0, 0.50, 0.50), 0.6 * (1.0 - smoothstep(-0.04, 0.04, cheek))); 120 // 目盛り:60個の小さい物と12個の大きい物
82   121 float minuteTick = sdBox(repeatAngle(p, 60.0) - vec2(0.65, 0.0), vec2(0.025, 0.004));
83 // 目:縦のカプセル 122 float hourTick = sdBox(repeatAngle(p, 12.0) - vec2(0.62, 0.0), vec2(0.06, 0.014));
84 vec2 eyeL = vec2(-0.2, 0.12); 123 color = paint(color, vec3(0.25), minuteTick);
85 vec2 eyeR = vec2(0.2, 0.12); 124 color = paint(color, vec3(0.12), hourTick);
86 float eyes = min(sdSegment(p, eyeL - vec2(0.0, 0.06), eyeL + vec2(0.0, 0.06)), 125  
87 sdSegment(p, eyeR - vec2(0.0, 0.06), eyeR + vec2(0.0, 0.06))) - 0.075; 126 // 針の影、時針と分針
88 color = paint(color, vec3(1.0), eyes); 127 color *= 1.0 - 0.3 * (1.0 - smoothstep(-0.02, 0.03, sdHands(p - vec2(0.015, -0.025), t)));
89 color = paint(color, vec3(0.35, 0.18, 0.10), abs(eyes) - 0.01); 128 color = paint(color, vec3(0.12, 0.12, 0.15), sdHands(p, t));
90   129  
91 // 瞳:マウスの方を見る(目の中から出ない様に長さを制限) 130 // 秒針:1秒毎にカチッと動く
92 vec2 look = m - (eyeL + eyeR) * 0.5; 131 float second = sdHand(p, fract(floor(t) / 60.0), 0.15, 0.6, 0.006);
93 look = look / max(length(look), 1.0) * 0.05; 132 second = min(second, sdCircle(p, 0.035));
94 float pupils = min(sdCircle(p - eyeL - look, 0.04), sdCircle(p - eyeR - look, 0.04)); 133 color = paint(color, vec3(0.85, 0.15, 0.15), second);
95 color = paint(color, vec3(0.12, 0.08, 0.06), pupils);    
96      
97 // 口:リングの下半分(リングと半平面の積集合)    
98 float ring = abs(sdCircle(p - vec2(0.0, 0.02), 0.3)) - 0.022;    
99 float below = p.y + 0.12; // y < -0.12 の部分が負    
100 color = paint(color, vec3(0.35, 0.18, 0.10), max(ring, below));    
101   134  
102 out_color = vec4(color, 1.0); 135 out_color = vec4(color, 1.0);
103 } 136 }


コメント(0)

まだコメントはありません。