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

自習室

こもります

Three.js の VertexShader でカードをうにょんと曲げる

.JavaScript .Shader

やったこと

VertexShader の練習として、シンプルな変形にチャレンジしてみました。

できあがりはこんな感じです。平面状のポリゴンを曲げてみます。

完成のソースコードを上げてあります

izmhr/curveCard_vShaderTest · GitHub

環境

概要

やったことをやや細かめに分解して順に説明していきます

  • texture を THREE.MeshBasicMaterial を使って THREE.PlaneGeometry に貼る
  • texture を THREE.ShaderMaterial を使って貼る
  • THREE.ShaderMaterial を VertexShaderを使って曲げる
  • 曲げ方を工夫する (VertexShader内で回転行列を使って、曲げる軸を傾ける)
  • シェーダに転送する uniform 変数を動かすことで、曲げのアニメーションを行う

ベースにしたサンプル

Textures (Three.js)

今回はこれをベースにしています。そのため以下の内容についてはここでは説明いたしません。scene, camera, renderer も良い感じに事前に設定してください

  • sphere に月のテクスチャを貼る
  • lightを適用する
  • THREEx で全画面化
  • THREEx でマウス操作で画面を回す
  • THREEx でキー入力を受け付ける
  • statsでフレームレートを計る
  • Detector でWebGL利用可不可を判定し、レンダラを切り替える

詳細1 (no shader)

no shader というか、正確には「プリセットのシェーダのみを利用して、自分ではシェーダは書かない」方式でまずは基本をおさえておきます。

texture を THREE.MeshBasicMaterial を使って THREE.PlaneGeometry に貼る

Shaderなどは特に気にせず画像を読み込んでテクスチャとしてメッシュに貼り付けるコードは以下の様な感じ。

var card = null;  // 必要に応じてグローバルで宣言しておく

// 中略

var cardTexture = THREE.ImageUtils.loadTexture('images/card.png');  // テクスチャの読み込み★
var cardGeometry = new THREE.PlaneGeometry(160, 256, 20, 32);       // ジオメトリの作成
var cardMaterial = new THREE.MeshBasicMaterial({map: cardTexture}); // 標準のマテリアルを作成し、★テクスチャをマップして利用する
cardMaterial.side = THREE.DoubleSide;                               // 両面描画する
cardMaterial.transparent = true;                                    // 透過、半透過の指定
cardMaterial.blending = THREE.NormalBlending;                       // ブレンディングの指定

card = new THREE.Mesh(cardGeometry, cardMaterial);                  // ジオメトリとマテリアルからメッシュ(シェーディングまで含んだ3Dオブジェクト)の作成
card.position.set(0, 0, -5);                                        // 位置の指定
scene.add(card);                                                    // シーンへの配置
テクスチャの用意

Threeでは、2の開場サイズ(128x128, 256x256 ...) でなくてもテクスチャとして読み込めますので、今回は 320x512 というサイズのトランプの画像を用意しました。

f:id:AMANE:20140926124006p:plain:h200

ジオメトリの用意

これに合わせて、ジオメトリの方も、160x256というサイズにしています。THREE.PlaneGeometry(160, 256, 16, 32); で、シーン中のサイズを指定して PlaneGeometry を作成しています。引数後半の 20, 32); は、このメッシュを表現する三角形ポリゴンを作る細かさで、ここでは、横方向に20分割、縦方向に32分割しています。ただの平面を変形せずに描画するのでしたら、こんな分割数は不要で、1, 1); という指定でも良いと思います。ここでは、あとで曲げ変形をするので、細かめにメッシュを切っておきます。

f:id:AMANE:20140926132342p:plain

上の画像のようにワイヤフレーム表示するには、以下の記述を加えます

cardMaterial.wireframe = true;
アルファブレンディングの指定

テクスチャに使ったカードの画像は、色深度32ビットで、アルファの値も持っている png です。先ほどの画像をクリックすると、透過している画像であることが分かると思います。マテリアルにアルファブレンディングを適用するにために、以下の記述を加えてあります。

cardMaterial.transparent = true;                                    // 透過、半透過の指定
cardMaterial.blending = THREE.NormalBlending;                       // ブレンディングの指定

結果は以下の様な感じです

f:id:AMANE:20140926132453p:plain:w400

詳細2 (using shader)

ここからは自分でシェーダを実装していきます

texture を THREE.ShaderMaterial を使って貼る

さっきは、プリセットのTHREE.MeshBasicMaterial を使ってマテリアル(つまりシェーディングの指定)を作りましたが、今回は同等の物をシェーダを使って実現してみます。jsのコードの方は以下の様な感じになります

var card = null;  // 必要に応じてグローバルで宣言しておく

// 中略

var cardTexture = THREE.ImageUtils.loadTexture('images/card.png');  // テクスチャの読み込み★
var cardGeometry = new THREE.PlaneGeometry(160, 256, 16, 32);       // ジオメトリの作成

var carMaterialShaderSimple = new THREE.ShaderMaterial({            // シェーダを自分で指定するマテリアルを作ります
  vertexShader: document.getElementById('vertexShaderSimple').textContent,  // 同じhtmlファイル内の id='vertexShaderSimple' で記述されています
  fragmentShader: document.getElementById('fragmentShader').textContent,    // 同じhtmlファイル内の id='fragmentShader' で記述されています
  uniforms: {
    texture: { type: 't', value: cardTexture}                               // uniform 変数として★テクスチャのデータを渡します
  }
});
carMaterialShaderSimple.side = THREE.DoubleSide;                    // 両面描画する
carMaterialShaderSimple.transparent = true;                         // 透過、半透過の指定
carMaterialShaderSimple.blending = THREE.NormalBlending;            // ブレンディングの指定

card = new THREE.Mesh(cardGeometry, carMaterialShaderSimple);       // ジオメトリとマテリアルからメッシュ(シェーディングまで含んだ3Dオブジェクト)の作成
card.position.set(0, 0, -5);                                        // 位置の指定
scene.add(card);                                                    // シーンへの配置

プリセットのマテリアル THREE.MeshBasicMaterial を使っていたところを、THREE.ShaderMaterial で作り替えています。

vertexShader / fragmentShader

Shaderの記述は、同htmlファイル内に<script></script> 要素として書いておきます。これを、上記の THREE.ShaderMaterial 内で指定しています。

<script id="vertexShaderSimple" type="x-shader/x-vertex">
varying vec2 vUv;                                             // fragmentShaderに渡すためのvarying変数
void main()
{
  vUv = uv;                                                   // 処理する頂点ごとのuv(テクスチャ)座標をそのままfragmentShaderに横流しする
  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);    // 変換:ローカル座標 → 配置 → カメラ座標
  gl_Position = projectionMatrix * mvPosition;                // 変換:カメラ座標 → 画面座標
}
</script>

<script id="fragmentShader" type="x-shader/x-fragment">
uniform sampler2D texture;                                    // uniform 変数としてテクスチャのデータを受け取る
varying vec2 vUv;                                             // vertexShaderで処理されて渡されるテクスチャ座標

void main()
{
  gl_FragColor = texture2D(texture, vUv);                     // テクスチャの色情報をそのままピクセルに塗る
}
</script>

この結果は、前節と同じになります。

THREE.ShaderMaterial を VertexShaderを使って曲げる

まずはシンプルに、紙をロールに巻き付けるような変形をしてみます。y軸周りに曲げます。

結果はこんな感じ

f:id:AMANE:20140926143300p:plain:w400

曲げのアルゴリズム

f:id:AMANE:20140926232433p:plain

平面上の任意点Pが、曲げるとTに移動するとする

P = (Px, Py, Pz)
T = (Tx, Ty, Tz)

ここで、曲げた結果、紙の長さ・面積が変わらない条件から

Px = 弧TS = Rθ
  θ = Px / R

曲げ後の点Tの座標は以下で求まる

Tx = R * sinθ
Ty = Py
Tz = R - Rcosθ
曲げの実装

これをVertexShaderに実装します

<script id="vertexShaderCurve" type="x-shader/x-vertex">
varying vec2 vUv;
uniform float curlR;
void main()
{
  vUv = uv;

  float theta = position.x / curlR;
  float tx = curlR * sin(theta);
  float ty = position.y;
  float tz = curlR * (1.0 - cos(theta));
  vec3 p = vec3(tx, ty, tz);

  vec4 mvPosition = modelViewMatrix * vec4(p, 1.0);
  gl_Position = projectionMatrix * mvPosition;
}
</script>

jsの方は、曲げ半径をunform変数としてシェーダに渡せるようにだけ、変更してあります

// 曲げるシェーダ
var carMaterialShaderCurve = new THREE.ShaderMaterial({
  vertexShader: document.getElementById('vertexShaderCurve').textContent,
  fragmentShader: document.getElementById('fragmentShader').textContent,
  uniforms: {
    curlR: { type: 'f', value: 100.0},         // シェーダに曲げの半径をuniform変数として渡す
    texture: { type: 't', value: cardTexture}
  }
});

曲げ方を工夫する (VertexShader内で回転行列を使って、曲げる軸を傾ける)

曲げ方が単純すぎるので、ちょっと凝ってみます。対角の頂点でカードを持ったときのように曲げてみます。 できあがりはこんな感じ。

f:id:AMANE:20140926233211p:plain:w400

曲げのアルゴリズム

前回はy軸に沿っていた円柱を傾ければ良さそうです。イメージはこんな感じ。

f:id:AMANE:20140926232448p:plain

シェーダに実装した曲げの式をそのまま流用したいので、紙の方を傾けます。ただし、曲げる軸を傾けたいので、実際には紙というオブジェクトを傾けるのではなく、紙の座標系を傾けて、その座標系で曲げ変形を適用し、その後座標系を戻してあげることにします。座標変換のイメージはこんな感じです

f:id:AMANE:20140926234806p:plain

rotZだけ傾ける座標変換を行う変換行列をM(rotZ) とすると、

P' = M(rotZ) * P

このP'に対して、前節と同じ変換を行う

θ = P'x / R
T'x = R * sinθ
T'y = P'y
T'z = R - Rcosθ

さらに、もとの座標系に戻すために、 -rotZ だけ傾ける
T = M(-rotZ) * T'
アルゴリズムの実装

これをVertexShaderに実装します。曲げの軸を傾ける角度 rotZ がuniform変数として追加されていることにお気をつけ下さい。

<script id="vertexShader" type="x-shader/x-vertex">
  varying vec2 vUv;
  uniform float rotZ;
  uniform float curlR;

  // const mat2 deformUV = mat2(1.0, 0.0, 0.0, 2.0);

  // GLSL Rotation About An Arbitrary Axis
  // http://www.neilmendoza.com/glsl-rotation-about-an-arbitrary-axis/
  mat4 rotationMatrix(vec3 axis, float angle)
  {
    axis = normalize(axis);
    float s = sin(angle);
    float c = cos(angle);
    float oc = 1.0 - c;
    
    return mat4(oc * axis.x * axis.x + c, oc * axis.x * axis.y - axis.z * s,  oc * axis.z * axis.x + axis.y * s,  0.0,
      oc * axis.x * axis.y + axis.z * s,  oc * axis.y * axis.y + c,           oc * axis.y * axis.z - axis.x * s,  0.0,
      oc * axis.z * axis.x - axis.y * s,  oc * axis.y * axis.z + axis.x * s,  oc * axis.z * axis.z + c,           0.0,
      0.0,                                0.0,                                0.0,                                1.0);
  }

  void main()
  {
    // vUv = deformUV * uv;
    vUv = uv;
    vec4 transp = rotationMatrix(vec3(0, 0, 1), rotZ) * vec4(position, 1.0);
    float theta = transp.x / curlR;
    float tx = curlR * sin(theta);
    float ty = transp.y;
    float tz = curlR * (1.0 - cos(theta));
    vec3 p = vec3(tx, ty, tz);
    vec4 backedp = rotationMatrix(vec3(0, 0, 1), -rotZ) * vec4(p, 1.0);
    vec4 mvPosition = modelViewMatrix * backedp;
    gl_Position = projectionMatrix * mvPosition;
  }
</script>

jsの方は、先ほどのコードに軸を傾ける角度を表すuniform変数を加えるのみです。

    uniforms: {
      rotZ: { type: 'f', value: Math.PI / 6}, // 30deg // 追加
      curlR: { type: 'f', value: 100.0},
      texture: { type: 't', value: cardTexture}
    }

回転の変換行列をつくるglslのコードは、下記サイト様から拝借いたしました。ありがとうございます

GLSL rotation about an arbitrary axis - Neil Mendoza

シェーダに転送する uniform 変数を動かすことで、曲げのアニメーションを行う

せっかくなので最後にアニメーションもしてみます。曲がり具合は、曲げの半径 curlR だけで変化させているので、それをフレームごとに動かしていきます。

VertexShader中の uniform 変数を書き換えるには、それを変数として定義し、中身を書き換えて行けば良いです。 シェーダは一個前の物と共通です。

var myShaderUniforms;   // グローバル変数で確保しておく

function init()
{
  // 中略
  // curve card
  myShaderUniforms = {
    curlR: { type: 'f', value: 0.0},          // この値を書き換える
    rotZ: {type: 'f', value: Math.PI / 6.0},
    texture: { type: 't', value: cardTexture}
  };
  var curveCardMaterial = new THREE.ShaderMaterial({
    vertexShader: document.getElementById('vertexShader').textContent,
    fragmentShader: document.getElementById('fragmentShader').textContent,
    uniforms: myShaderUniforms                // uniformsに変数を与えておく
  });
  // 後略
}

function update()
{
  //中略
  // 200 を中心として 150の振幅で sin 関数で揺らす
  var _curlR = 200.0 + 150.0 * Math.sin(clock.getElapsedTime() * 2.0);
  myShaderUniforms.curlR.value = _curlR;  // uniform変数としてシェーダに送り込む
  //その他更新処理
}

以上で、冒頭の動画のものが完成します。

最後に

今回は、シェーダで頂点座標だけ書き換えていて、合わせて変形するはずの法線に関しては処理をしておりません。そのため、まともにライティングをすると、歪ませたときにおかしなことになるはずです。

繰り返しになりますが、今回作った物はgithubにあげてありますので、もしよろしければご活用下さい