GLSLで画面端に到達したらループさせる表現

最近GLSL(OpenGL)でグラフィックスやアニメを描いて遊んでいますが、そのTipsをゆるゆると書いていきます。Web上にあるチュートリアルと内容がかぶることもあると思いますが、ご愛嬌ということで。

--------------------------

前書き

今回は掲題の通り、「移動した物体が画面端に到達したら、反対側に移動させる」という表現について考えます。

犬がトコトコ歩いていて、画面から消えたら反対側の端からひょこっと出てくる、という動画をGLSLで作りたいときに、どうコードを書けばよいでしょうか。

要は↓こういう動きをさせたい、という話です。

基本的な考え方

「if文を使えば単に実現できるでしょ」と言いたいところですが、そうは問屋が卸してくれません。というのも「GLSLではif文の処理が重い」ため。CPUではなくGPUに直接命令を渡すGLSLでは、普通のプログラミング言語とは違うお作法がある…というのは頭の片隅に止めておく必要があります。

というわけで、if文を使わずにこの空間的ループを実現していきます。

今回のコード

ShaderToyでソース公開しています。日本語でコメントも付記。

ポイント

1. 移動させる物体を描くための処理は関数化
2. 位置のループは経過時間timeをfractすることで実現する
3. ちゃんと終端処理をやる

ポイント1 : 物体を描くための処理は関数化

物体を移動させるのと物体を描く処理を同時にコーディングすると、脳みそがパンクします。思考がクラッシュします。できるだけ思考をシンプルにするため、目的が異なる処理はなるべく個別のモジュールにしましょう。(これはほかのプログラミング言語と同じ話。ただ、分けないほうが処理が早くなるような場合には悩ましいですが…)

個別のモジュールに分けると、メインの処理はシンプルで分かりやすくなります。

void mainImage( out vec4 fragColor, in vec2 fragCoord ) {
  /* 座標系の定義。画面中央を(0,0)の原点とする。*/
  vec2 uv = fragCoord.xy / iResolution.xy ;
  uv -= .5; //
  float asp = iResolution.x/iResolution.y;
  uv.x *= asp; 

  /* パラメータの設定 */
  float speed = 0.1; // 移動速度
  vec2 o1 = vec2(-0.5*asp,-0.5);  // 原点を合わせるためのオフセット
  vec2 ratio = vec2(1.1,1.1); // ループ幅のマージン係数

  /* 座標を移動させる */
  vec2 p = loop(uv, speed, o1, ratio, asp);

  /* 球体を描く。中心は時間に従って移動する */
  vec3 col = ball(p, 0.1) ;

  /* 描画;点に色を設定する */
  fragColor = vec4(col, 1.) ;
}


今回のコードでは、ソースの9〜19行目がボールを描く関数を記述し、それをmainImage関数の中(57行目)で呼び出しています。ボールを描く関数は、その中心座標 uv と半径 r を引数にとります。uv に何を渡せば「移動するボール」が描けるのか?は後述。

vec3 ball(vec2 uv,float r) {
    vec3 col=vec3(0.);  // 変数の宣言&初期化
    float d = length(uv); // ベクトルの大きさ。 d<rなら色を塗る
    const vec3 p_color = vec3(.54,.2,.85); // 塗りつぶしの色

    /* d<rの点に色を塗る。原点から遠いほど色が薄くなる。 */
    col += step(d,r)*(1.-d/r)*p_color;
    /* ↓こちら↓はグラデーションなしで塗る場合の式。 */
    //col += step(d,r)*p_color;
    return col;
}


ポイント2: 位置のループは経過時間timeをfractすることで実現する

Shadertoyのソースコードでは22〜32行目に該当箇所があります。経過時間 time がなんのことかわからない…という方は以下の記事を先に読んでおいてください。

点や物体を移動させる場合、位置座標に time から取得した時間をオフセットとして加算してやればよいです。ただ、そのまま使うと画面から消えた物体が戻ってこないため、「周期性のある関数」を使います。(例えばsin関数やcos関数など。円運動や振り子の軌跡はこれで表現できます。)

今回の場合「画面端まで等速運動をしたあとに反対端に現れる」という挙動のため、fract関数を使用します。この関数は入力値の小数部を返す関数であるため、経過時間timeを渡すと、戻り値は 0→...→0.999... →0→...→0.999...→0→... という周期性を持ちます。「0から1に向かって進み、端まで行ったら0に戻る」という挙動になるわけです。

なお、fractが返す値の範囲(0〜0.999...)をそのまま足したり引いたりすると、座標系の設定によっては位置ズレする場合があります。

例のソースコードでは 画面中央が原点、x,y座標はそれぞれ[-0.5, 0.5]の範囲に標準化されているため、オフセット o1 を足して調整しています。

  float aspect_ratio = iResolution.x/iResolution.y;
  vec2 o1 = vec2(-0.5,-0.5);  // 原点を合わせるためのオフセット

  vec2 t = vec2 (  
          fract(iTime)*aspect_ratio, 
          fract(iTime)
          ); 
  vec2 p = vec2(uv.x, uv.y)  + t + o1 ; 
  

ポイント3: ちゃんと終端処理をやる

「終端処理」はほぼ造語です。ここでは「物体が画面端に消えるとき、および反対の端から現れるときに、違和感なく連続的に表示されるための処理」という意味です。

これはダメな例を見てもらったほうが早いと思います。前節までの内容に剃って実装すると、画面端で物体が急に消えて/現れてしまい、違和感が生じます。

※画面中央での不連続な飛びはgif化した際の不具合です... 

この原因は、移動幅が画面の座標とピッタリ一致しているため。「円の中心座標が画面端に到達したら、円が消える」という挙動になっており、その時点で画面に残っている部分は一瞬で消えてしまうことになります。

そこで、移動する幅に少しマージンをもたせ、物体が画面外に完全にハケてから反対端にあらわれるよう調整します。具体的には、移動幅のfract(iTime)を1.1倍します。

float ratio = 1.1 みたいに1次元で書いてもOKですが、下記のコードでは、x,y方向それぞれ個別に調整できるようvec2型で定義しています。

  float asp = iResolution.x/iResolution.y;
  vec2 o1 = vec2(-0.5*asp,-0.5);  // 原点を合わせるためのオフセット
  vec2 ratio = vec2(1.1,1.1); // ループ幅のマージン係数

  vec2 t = vec2 (  
          (fract(iTime))*asp*ratio.x, 
	      (fract(iTime))*ratio.y
          ); 

  vec2 p = vec2(uv.x, uv.y)  + t + o1 ; 

完成形

最後に、ポイント1〜3を踏まえて実装したソースコードとアニメーションを公開しておきます。これでスムーズに球体の移動がループするようになりました。

これはただの球体なのでつまらない動画ですが、応用すると犬がトコトコ歩いたりクジラが泳いだりするようなアニメーションにも使えます。何か表現のヒントになれば幸いです。


面白かった、ためになったと感じた方にサポートいただけると励みになります。いつか展示会やイベントをやるための積立金として、あるいは飢死直前金欠時のライフラインとして使わせていただきます。