読者です 読者をやめる 読者になる 読者になる

WonderPlanet DEVELOPER BLOG

ワンダープラネットの開発者ブログです。モバイルゲーム開発情報を発信。

Androidで4k intro入門

Android OpenGL ES Shader アルゴリズム

あけましておめでとうございます、エンジニアの成田です。本年もよろしくお願いいたします!

それでは早速ですが、今回のブログはdemosceneのintroという分野について紹介させて頂きます。
demosceneとは小さいサイズのプログラムを特徴とする非インタラクティブな映像(と場合によっては音楽も)作品を表示するコンピュータアート文化で、中でもintroはその実行ファイルサイズに4kバイトや64kバイトなどの上限を設けたサブカテゴリのことです。

例えば5年前の4k intro作品でこういうものがありました。

もの凄いですよね、このクオリティのものを4kのプログラムで書くというのですからもうエクストリーム・スポーツのようなものです。このElevatedはなんでも4kバイトの制限を超えるためにジオメトリのみならず、テクスチャやBGMを起動時に生成しているとか。
他にも「4k intro」などのキーワードで検索すると素晴らしい作品がたくさん出てきます。

正直私もこの分野は完全に見る側で作り方は全然詳しくないのですが、この機会にちょっと勉強をして初歩的なプログラムを作って動かしてみました。
今回はintroのファイル制限にも目を瞑ることにします(笑)

1.レイマーチング

introでよく用いられるアルゴリズムとして、レイトレーシングの一手法であるレイマーチングがあります。introではCPU側からシーンに表示するオブジェクトのポリゴンの頂点情報等を渡すことはあまり行いません。ファイルサイズの制限というルール上、事前にモデリングしたオブジェクトやテクスチャを持つことは難しいためです。そこでポリゴンでなく、数学的な式からオブジェクトをレンダリングできるレイマーチングが用いられるわけです。さらにレイマーチングを構成する要素としてスフィアトレーシングとdistance functionがあります。これらについて少し説明します。

a.スフィアトレーシング

スフィアトレーシング(Sphere Tracing)は幾何学的な距離を利用してオブジェクトのサーフェイスを検出するアルゴリズムです。スフィアトレーシングのアルゴリズムはそれほど難しくなく、ざっくりと言えば「現在のレイの到達点」という概念があり、反復計算によって特定長のレイを継ぎ足していくイメージです。具体的には以下のような手順をとります。

  1. 視点からレイトレースをスタート。現在のレイ到達点は視点。
  2. レイ到達点から最近傍のオブジェクトまでの距離を計算し、レイの方向へ同じ長さだけレイを継ぎ足す。
  3. 2の手順を特定回反復する。
  4. 最後に取得した最近傍オブジェクト距離が十分に小さい場合はオブジェクトに衝突したと見なす。さもなければ衝突しなかったと見なして計算終了。

spheretracing
こんな感じです。

b.distance function

上のスフィアトレーシングのアルゴリズムにおいて最近傍のオブジェクトまでの距離を定義するのがdistance functionです。
引数として現在のレイの到達点を受け取り、シーンのオブジェクトのうちで最近傍のオブジェクト(のサーフェイス)までの距離を返すわけです。例えば、原点に配置した球のdistance functionは次のようになります。

float distanceFunc(vec3 pos) {  
  return length(pos) - radius;  
}  

意外と単純な式で表せます。実際に検算してみると数学的に正しいことが分かると思います。
もちろんさらに式をかませることで他のプリミティブ図形を表示したり、拡大縮小・回転・平行移動など、アフィン変換的なこともできます。こちらIñigo Quilez - fractals, computer graphics, mathematics, demoscene and moreにまとまっているので、興味があれば見てみてください。

それではこれらを実際に実装してみます。

2.実装

特に意味があるわけではないんですが、今回はAndroidのOpenGL ESで動かしてみました。AndroidでのOpenGL ESアプリ作成の方法は以前のAndroidでNDKを使わないOpenGL ES 2.0の記事を参照してください。今回はシェーダ部分のみソースコードを載せることにします。

まず準備としてレンダリングに都合が良くなるように座標系の正規化を行います。スクリーン中央が(0,0)、左下が(-1,-1)、右上が(1,1)となるような座標系です。
座標系の正規化が正しく行われているか試しに出力してみます。

今回は一般的なポリゴンによる3D表示は行いませんので、ポリゴンは単純に画面全体((-1,-1)〜(1,1)の領域)に張ります。射影変換も行いません。このポリゴンをレンダリングして、主にフラグメントシェーダで好きな絵になるように弄っていくわけです。

バーテックスシェーダはほとんど何もしていません。

attribute vec3 a_Position;  
  
void main()  
{  
  gl_Position = vec4(a_Position, 1.0);  
}  

フラグメントシェーダは以下。u_resolutionはCPUから渡したOpenGLサーフェイスのサイズです。

precision highp float;  
uniform vec2 u_resolution;  
  
void main() {  
  // フラグメントの座標の算出  
  // 画面左下が(-1,-1)、右上が(1,1)となる座標系。厳密には縦横比で長い方の軸の取り得る値は[-1,1]にはならない  
  vec2 pos = (gl_FragCoord.xy*2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);  
    
  // 座標を出力してみる  
  gl_FragColor = vec4(pos, 0.0, 1.0);  
}  

実行してみます。
device-2015-01-04-000820
想定通りに座標系の正規化が行えたようです。

それでは次はレイトレーシングの準備としてカメラとレイを定義します。
カメラ位置を(0.0, 0.0, 3.0)で原点方向に向けたものとします。ではまたテスト出力してみましょう。

precision highp float;  
uniform vec2 u_resolution;  
  
void main() {  
  // フラグメントの座標の算出  
  vec2 pos = (gl_FragCoord.xy*2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);  
  
  // カメラとレイの定義  
  vec3 camPos = vec3(0.0, 0.0,  3.0);  // カメラ位置  
  vec3 camDir = vec3(0.0, 0.0, -1.0);  // カメラ方向ベクトル  
  vec3 camUp  = vec3(0.0, 1.0,  0.0);  // カメラ上方向ベクトル  
  vec3 camSide = cross(camDir, camUp);  // カメラ右方向ベクトル  
  float targetDepth = 0.1;  // 焦点深度  
  vec3 rayDir = normalize(camSide*pos.x + camUp*pos.y + camDir*targetDepth);  // レイ方向ベクトル  
  
  // レイ方向ベクトルを出力してみる  
  gl_FragColor = vec4(rayDir.xy, -rayDir.z, 1.0);  
}  

結果
device-2015-01-04-175649
正しくカメラとレイが定義されていれば上のような出力になります。

それではいよいよオブジェクトを出してみます。
レイマーチングのループ処理を追加し、上のdistance functionの説明にて書いた球のdistance functionを定義してみます。あとカメラもちょっとずらします。

precision highp float;  
uniform vec2 u_resolution;  
  
// distance functionの定義  
const float radius = 1.0;  
float distanceFunc(vec3 pos) {  
  return length(pos) - radius;  // 位置は原点、半径1.0の球を表すdistance function  
}  
  
const int loopCnt = 16;  
void main() {  
  // フラグメントの座標の算出  
  vec2 pos = (gl_FragCoord.xy*2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);  
  
  // カメラとレイの定義  
  vec3 camPos = vec3(0.0, 0.0,  2.0);  
  vec3 camDir = vec3(0.0, 0.0, -1.0);  
  vec3 camUp  = vec3(0.0, 1.0,  0.0);  
  vec3 camSide = cross(camDir, camUp);  
  float targetDepth = 1.0;  
  vec3 rayDir = normalize(camSide*pos.x + camUp*pos.y + camDir*targetDepth);  
  
  // マーチングループ  
  float dist   = 0.0;  // distanceFuncの返り値  
  float len    = 0.0;  // 現在のレイ発射点から進んだ距離  
  vec3  rayPos = camPos;  // 現在のレイ位置  
  for (int i = 0; i < loopCnt; i++) {  
    dist = distanceFunc(rayPos);  
    len += dist;  
    rayPos = camPos + rayDir*len;  
  }  
  
  // レイがヒットしたら白、ヒットしなかったら黒  
  if (abs(dist) < 0.001) {  // distanceFuncの結果が十分に小さいならヒットしたと判断  
    gl_FragColor = vec4(vec3(1.0), 1.0);  
  } else {  
    gl_FragColor = vec4(vec3(0.0), 1.0);  
  }  
}  

結果
device-2015-01-04-181826
球を出すことができました。正しくレイマーチングできているようです。でもこの絵ではちょっと寂しいのでランバート反射のシェーディングを行ってみます。

ランバート反射を行うためには法線ベクトルが必要ですが、今回は通常のポリゴン描画のように頂点情報がもらえませんのでシェーダーで自力で法線ベクトルを検出しなければなりません。
今回はこういうシチュエーションでよく利用されているという、レイがオブジェクトに衝突した座標に対してx、y、z軸方向に微小変化させ、各軸方向のdistance functionの傾斜を検出する方法を利用します。おそらく各軸方向にある種の微分をしているような感じになるんだと思います。

それでは実装です。

precision highp float;  
uniform vec2 u_resolution;  
  
// distance functionの定義  
const float radius = 1.0;  
float distanceFunc(vec3 pos) {  
  return length(pos) - radius;  
}  
  
// 座標の法線ベクトル取得関数  
vec3 getNormal(vec3 p){  
  const float d = 0.0001;  // 微小変化量  
  return normalize(vec3(  
    distanceFunc(p + vec3(  d, 0.0, 0.0)) - distanceFunc(p + vec3( -d, 0.0, 0.0)),  
    distanceFunc(p + vec3(0.0,   d, 0.0)) - distanceFunc(p + vec3(0.0,  -d, 0.0)),  
    distanceFunc(p + vec3(0.0, 0.0,   d)) - distanceFunc(p + vec3(0.0, 0.0,  -d))  
  ));  
}  
  
const vec3 lightDir = vec3(-0.577, 0.577, 0.577);  // 平行光源の光線方向ベクトル  
const int loopCnt = 16;  
void main() {  
  // フラグメントの座標の算出  
  vec2 pos = (gl_FragCoord.xy*2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);  
  
  // カメラとレイの定義  
  vec3 camPos = vec3(0.0, 0.0,  2.0);  
  vec3 camDir = vec3(0.0, 0.0, -1.0);  
  vec3 camUp  = vec3(0.0, 1.0,  0.0);  
  vec3 camSide = cross(camDir, camUp);  
  float targetDepth = 1.0;  
  vec3 rayDir = normalize(camSide*pos.x + camUp*pos.y + camDir*targetDepth);  
  
  // マーチングループ  
  float dist   = 0.0;  
  float len    = 0.0;  
  vec3  rayPos = camPos;  
  for (int i = 0; i < loopCnt; i++) {  
    dist = distanceFunc(rayPos);  
    len += dist;  
    rayPos = camPos + rayDir*len;  
  }  
  
  if (abs(dist) < 0.001) {  
    // ヒットした座標の法線ベクトル計算  
    vec3 normal = getNormal(rayPos);  
    // とりあえずランバート反射で色を付けてみる  
    float diffuse = clamp(dot(lightDir, normal), 0.0, 1.0);  
    gl_FragColor = vec4(vec3(diffuse), 1.0);  
  } else {  
    gl_FragColor = vec4(vec3(0.0), 1.0);  
  }  
}  

結果
device-2015-01-04-222748
ランバート反射によるシェーディングができました。

最後に反復用のdistance functionをかませることで球をいっぱい出してみます。画面奥方向へも図形が反復するので、奥を描画するためにレイマーチングの反復回数を倍にしましょう。

(変更しない場所は省略)  
// Repetition function(反復関数)  
vec3 opRep(vec3 p) {  
  vec3 c = vec3(8.0);  
  return mod(p,c) - 0.5*c;  
}  
  
// distance functionの定義  
const float radius = 1.0;  
float distanceFunc(vec3 pos) {  
  return length(opRep(pos)) - radius;  
}  
  
const int loopCnt = 32;  

結果
device-2015-01-04-230821
なんかいっぱい出た!

今回はこの辺で。機会があればもう少し豪華にしてみたいですねー。