逆引き風!Three.jsでゲームを作る時に役立ちそうなTips

  • 15
    いいね
  • 0
    コメント

まずはじめに

お前は誰だ

 Jey-enと申します。業務でC#を、JavaScriptは業務と趣味で合わせて1年という、素人と知ったかぶりの狭間を生きております。

こんなん作った

Three.jsでゲームっぽいのが作れるかなと思い、このくらいまでやってみました

大体ね、「3DぐらふぃっくがWebで出来るようになった!おっしゃゲーム作ったろ!」なんて、脳筋すぎると思いませんかね:fist_tone3:

そのゲームっぽいものを作りながら思いついた、使えそうな小技をお伝えできればと思います。
他の方がやっているように、一つのテーマで完結まで行くという形ではなく、タイトルにもあるように【逆引き辞典風】な何かです。

対象としている人:

Three.jsを使って、【何かモデルを表示する】とかできて、次は何をしようかと考えてる人
よせばいいのにUn●tyじゃなくてThree.jsでゲームっぽいのを作ろうとしてる人
せめて「Scene」に「カメラ」と「モデル」を追加すれば表示できる、という所までわかってる人


というように、多くの人に必要かとか、そういうことは一切考えずに「独断と偏見」のみでTipsを分類しました。
全部読もうと思わず、気になるところをつまみ食いをするのが一番いいかと思います。見出し一覧からGoです。全部知っていたらBackボタンGoです。
それでは、ごゆるりとお付き合いくださいませ。

Lv1:ゲーム以外のアプリ・サイト等でも(多分)使えそうなもの&初歩的なもの

☆キャラに、カメラの方向を向いてほしい(簡易版)

「あの子を振り向かせたい」、プログラムなら確実に出来ます。Three.jsなら2次元じゃなくて3次元だぞ!って言っていいですか。ダメですか。そうですか。

手順としましては、
1・視点の位置から、あの子(被写体)に対して向かうベクトルを用意する
2・それを単位ベクトルにする(normalize)
3・魔法の呪文を唱える(atan2)
4・キャラの向きにセットする(rotation.y)

// charaMesh が、向かせたいキャラのメッシュオブジェクトとします
var tmpV = new THREE.Vector3();
tmpV.copy(camera.position);
tmpV.sub(charaMesh.position);     //1.向かうベクトルの用意
tmpV.normalize();                 //2.単位ベクトル化

var angle = Math.atan2(tmpV.x, tmpV.z);    //3.魔法の呪文
charaMesh.rotation.y = angle;               //4.向きに適用

 Vector3.angleTo が使えるかな~と思ってたのですが、 angleTo は、値が「角度の絶対値」しか返ってこないんですよね。ダメでした。アレは罠です。向かせたいなら、Math.atan2 を使いましょう。
ここで検出できる向きは、「Y軸」の向きです。コレを応用すると、「進行方向にキャラを向かせる」とかも実現できるようになります。簡単かつ出来ることが多いのがこいつです。

☆地形の上に立たせたい(メッシュからの高さ検出)

ド定番ですね。これを1つずつ必要な要素を切り出すと、
1.「立たせたい(上に置きたいもの)の、はるか上空のポイント(Vector3)を用意する
2.「1」から、真下に向かう「線」を用意する(Raycaster)
3.その「線」と、「地面となるもの」の、ピンポイントの衝突点を探す(Raycaster.intersectObject)

という流れになります。

XとZは元の位置を利用し、Yだけ「はるか上空」に置くのは、「指定した一方向に対してしか、衝突を調べてくれない」からです。ここに元位置のYをそのままセットしてしまうと、「自分が今いる場所から、下の場所の高さ」しか正確な位置が調べられず、階段で言うと「降りる」しかできなくなります。それは困るため、「下向きに調べれば全部分かる」ようにするため、Yだけ大きい位置にしてあります。

 // basePos というVector3型に、「立たせたいもの」のPosが入ってるとします

var TopOverPos = new THREE.Vector3(basePos.x, 65535, basePos.z); //1.はるか上空のポイントを用意
var downVect = new THREE.Vector3(0,-1,0);     //下向きのベクトルのみが入ったVector3を用意

var ray = new THREE.Raycaster(TopOverPos, downVect.normalize());  //2.真下に向かう線がコレ

var maxY = 0;  //衝突対象が複数あった場合、一番「高い」ものを取得するようにします
var objs = ray.intersectObjects(scene.children, true);   //衝突点検出!
for (var i = 0; i < objs.length; i++) {
    if(maxY <  objs[i].point.y)
    maxY = objs[i].point.y;
}

//return maxY;  //当然、この maxY  に、検出できた高さが入ってきます。

basePos.y = maxY;

なお、上のサンプルは「scene全部にあるもの片っ端から高さを検出する」ものとなっています。計算は「メッシュの頂点量」に比例して時間がかかりますので、sceneの中に人物用のメッシュとかあったら当然ソコからも調べて、まぁソレは重くなります。
「コレが地面!」と、決めたものがある場合は、

var objs = ray.intersectObject(zimen_mesh);

というように、メッシュ単品からも取得できます。こちらのほうがより実践的で、現実的でしょう。

☆3D上のポイントを画面に出したときの2D位置を知りたい(スクリーン座標)

ゲームを良くする人であれば、「キャラのすぐ上や下に表示されてるHP」とかが、ピンとくる感じでしょうか。
画面は3D、でもHPは2Dでさくっと表示したい。そういうときに「3D上のとある位置は、2D表示にするとドコに来るのか、が知りたくなります。
コレは、しばしば【スクリーン座標】という呼ばれ方をするので、3Dプログラムをするなら覚えておくと幸せかもしれません。
そのスクリーン座標をThree.jsで得るためには、こんな感じになります。

 // basePos というVector3型に、「2Dにしたい位置」のPosが入ってるとします

var screenV = new THREE.Vector3();  //入れものを用意
screenV.copy(screenV);             //値を複製。こうしないと、値を乗っ取ってしまうので。
screenV.project(camera);           //この project というメソッドで、あっさり取得できます。だがしかし!

//この値は、 -1 ~ +1 で返ってくる(左&下が-1、中心がゼロ、右&上が+1)ので、画面サイズに直すにはもう一工夫

if(screenV.z < 1.0)  //1.0を超える=カメラの裏側にある、ということ!
{
 var scr_pos_x, scr_pos_y;
 scr_pos_x = screenSize.Width * 0.5 + (screenV.x * 0.5 * screenSize.Width);
 scr_pos_y = screenSize.Height * 0.5 + (screenV.y * -0.5 * screenSize.Height);
}

イメージとしてはこんな感じ
screenPos.png

☆逆に、クリックした位置を3D座標に変えたい

これはもう、非常にわかりやすいものを他の人が書いてくださってるので、お任せします
上の「Project」の逆、unproject と、最初に書いた Raycaster の合わせ技ですね。

☆キャラを動かした時の「キャラの向きの前方」の位置を知りたい(ローカル座標のワールド座標化:非ボーン)

これはもう、非常にわかりやすいものを他の人が書いてくださってるので、お任せします
リンク先のVector4型「forward」が、いわゆるローカル座標(キャラを中心とした座標)になります。

☆オススメ!Canvas重ねのススメ

もうThree.js関係ないんじゃね?と言われそうな勢いですが。
ゲームを作る時、体力ゲージとか得点とか、敵のマーカーとか、いわゆる「2D要素」を表示したいことは多々あります。
このとき、一昔前であればDirectDrawとか、2Dを弄るための命令はありました(何年前の話だ)。
んじゃあ、Three.jsではどうしたら良いのでしょう?

Three.jsが乗っかっているHTMLには、ご存知「Canvas」の「2dContext」という、2D要素をガリッガリに書きまくれる優れたコントロールがあります。そうです。こいつに書いてしまえばいいのです。
どうするかと言うと、「Three.jsの3D表示を行っている要素に対し、全く同じ位置&全く同じサイズのCanvas要素を用意して重ね、重ねたヤツに対して描画する」のです!

コレ、あっさり気づく人は気づくでしょうけど、気づかない人はずっと気づかないくらいの盲点的要素な気が。
Canvasは透明&透過が許されるので、下にあるものを表示したまま、円とか四角とか自由に書けます!
コレは便利ですし、管理も楽です。いやーイイですよコレ。

重なりはZ-indexで指定します。z-indexが大きいほど、手前に来ます。

<!-- 3D描画領域のためのdiv要素を配置 -->
<div id="canvas3d" style="position:fixed; top:0px; left:0px; width:100%; height:100%; z-index: 1; background-color: #000000;"></div>
<!-- その真上に、2D描画領域のためのdiv要素を配置 -->
<canvas id="canvas2d" style="position:fixed; top:0px; left:0px; width:100%; height:100%; z-index: 100; background-color:rgba(0,0,0,0);"></canvas>

なお、コレをやると、多くのThree.jsのサンプルで適用されている「【ページ】にThree.jsレンダー用のdivエレメントを追加(container) して、それに対してレンダーを適用(renderer.domElement)」が、動かなくなります。そのために、
【特定されたdivを、Three.jsの表示結果出力用に指定する】という処理が必要になります。まぁごく簡単な変更ですが

var canvas3D = document.getElementById('canvas3d');
canvas3D.appendChild(renderer.domElement);

そして、上に重なっている[ canvas2d ]のcontext2Dに対して、好き放題書きます。ですが、良くCanvasのサンプルなどでおいてある、「まず黒く塗りつぶします」的なことはやったらダメです(当然、Three.jsの3D結果が見えなくなります)

//conteText2Dは、canvas2dのcontextです。
conteText2D.clearRect(0, 0, conteText2D.canvas.width, conteText2D.canvas.height);  //2D描画結果のクリア。これはよくあるので、そのまま
//conteText2D.fillRect(0, 0, conteText2D.canvas.width, conteText2D.canvas.height);   これはやりません!

あとは、conteText2Dに対して、好き放題にfillやstrokeするだけです。

Lv2:使いみちは多いんだけど、初歩的とは言いづらいもの(初級者以上)

☆キャラの手に、持たせたい

ここでいう「キャラ」とは、一通りボーンが組まれており、かつアニメーションさせて動くようなキャラクターです。
アニメーションさせるということは、位置が動きまくります。当たり前です。手の位置なんて、上半身とか腕の上げ角度とか、それはもういろんな要素に引きずられて動きまくります。
じゃあその「動きまくる」ものに対して、持たせる=位置を合わせるには、どうすればよいのでしょう?

1.「持たせる場所」を、しっかり特定する。 
 これは、いわゆる「ボーン」を特定させます。3Dモデルを作った人がキチンとした方&きちんとした人が作ったインポーターなら、【 mesh.skeleton.bones[] 】 にボーンが、そしてbonesの中のBoneオブジェクトのname に、特定できそうな名前で入っています。
たとえば例のMMDミクであれば、mesh.skeleton.bones[54] が、右手の手首のボーンです。
2.そのBone型に対して、 add する
このBone型(Three.Bone型)に対し、なんとSceneと同じように add が出来るのです。
そう、その add で持たせたいオブジェクト(mesh等)を追加するだけで、そのボーン位置にぴったりと表示するようにしてくれます。
 
 んが、ここで少しだけ問題点。
negi_err.png

 これでそのまま「add」するだけだと、いわゆる「ぴったりすぎる」のですね(角度もアレですが)。手首のボーン位置なので、ぴったりそのまま、手首にくっついてきます。めり込んでます。これじゃダメです。リ○カっぽくてアウトです。そのため、「手首から少しずらし、手の平の位置で持たせる」のが望ましいですね。


//キャラモデルがmesh。 コレは、ボーン組のmesh=SkinnedMesh であることが大前提。
//手に持たせたいものを emonoMesh とします
//なお、コレは毎フレームやる必要はなく、1回だけ行えばいい。

mesh.skeleton.bones[54].add(emonoMesh ) ;   //この 54 番ボーンが、手首のボーン。ここに対して add する

var transMX =  new THREE.Matrix4();
var position = ( new THREE.Vector3()).set(-0.9,-1.1,0); //これが、手首から見た、手の平の位置。
var scale = ( new THREE.Vector3()).set(1,2,1);      //縦長にする
var quaternion  = (new THREE.Quaternion()).setFromAxisAngle((new THREE.Vector3().set(1,0,0)), Math.PI / 2); //角度をx軸からみて90度回転

transMX.compose( position, quaternion, scale );  //各要素をMatrixとして確定させます。
emonoMesh.applyMatrix(transMX);      //【持たせた】物体に対して、移動を適用させます。

この移動と変形と回転を適用させると、手首にしっくりくるようになります
oknegi.png

この「applyMatrix」で、【持たせた】方の物体のMatrixを変更します。Matrixなので、当然角度や大きさも変更自由です。ちょうどよい大きさを見つけましょう。

☆キャラの「指先」の座標を知りたい(ボーンを使ったローカル座標のワールド化)

ここでいう「キャラ」とは、一通りボーンが組まれており、かつアニメーションさせて動くようなキャラクターです。
アニメーションさせるということは、位置が動きまくります。当たり前です。手の位置なんて、上半身とか腕の上げ角度とか、それはもういろんな要素に引きずられて動きまくります。
じゃあその「動きまくる」ものの、【動いた後の位置】を知るためには、どうすればよいのでしょう?

ん?上の「ボーンに持たせる」と、何が違うのかって?
こちらは、「その時点での位置を知ること」を目的としています。ずっとくっついてくるのではなく、その場所に残るエフェクトなどが設置できるようになり、
enbu.png

こんな感じのことができるようになります。イイでしょ?ってことでやり方です

var tmpV = new THREE.Vector3();              //入れ物を用意
var localAddPos = new THREE.Vector3();        //取得したいボーンからの、ローカルに進んだ位置として作成
localAddPos.set(-0.1,-0,1);                      //指先になるように、進めてみた

tmpV.copy( mesh.skeleton.bones[60].localToWorld(localAddPos));  //60番は右人差し指の第三関節のボーン

このObject3D.localToWorld というメソッドで、ボーン座標+ボーンのローカル座標に値を加えたものを、ワールド座標にできます。(Object3Dクラスが持っているメソッドなので、別にBoneじゃなくても使えます)
コレは指先とか銃口とか、ゲーム用途には使いまくりなメソッドです。覚えておきましょう。

サンプル的なものはこちら

Lv3:ゲーム作る気じゃなければ必要なさそうなもの(習熟度ばらばら)

☆物体と物体が当たっているかどうかを知りたい

いわゆる「アタリ判定」というものを調べるのに良く使われるのは、 BoundingSphereというものです。
コレは、「そのメッシュ(モデル)の全頂点が入りきるように作られた、ちょうどいい大きさの球体」です。BoundingSphereは、何もしなければ普通にメッシュを読んだときに自動的に作られ、移動すればBoundingSphereもくっついて移動します。イメージとしては、ふぉーすふぃーるどです(雑)
 一番最初に書いた「メッシュからの厳密なアタリ判定」よりも、実質的には「2点間の距離」の計測で完結するBoundingSphere同士の判定の方が、圧倒的に高速です。キャラクターにも「玉」となるオブジェクトにも、このBoundingSphereは作られますので、BoundingSphere同士で衝突判定を行うのは、てっとり早い方法でしょう。
BoundingSphere同士の衝突判定は、 intersectsSphereで得ることができます。

//ここは、モデル読み込み後、一回だけ行う
if(charaMesh.geometry.BoundingSphere == null){ charaMesh.geometry.computeBoundingSphere();} //なかったら作る。
//少しだけ小さめにする
charaMesh.geometry.BoundingSpher.radius *= 0.8;
//ここは毎フレーム行う
 if(charaMesh.geometry.BoundingSphere.intersectsSphere(tamaMesh.geometry.BoundingSphere)
{
  console.log('死んだ!');
}

 小さくしている理由は、この方法、試してみれば分かるのですが、そのままの大きさではゲームに使うには「予想以上にアタリ判定が大きく思う」ことでしょう。頭の先から足の先まで、それが奥行きも全部すっぽりなので、見えてない所まで当たるように思えます。
そのため、気持ち小さくして、見た目との違和感を減らしています。

ところでこの geometry.BoundingSphere は、「視野判定」にも使われます。視野判定とは、「カメラから見える範囲に、そのキャラクターが入っているかどうか」の判定です。
ここの判定で外れると、そのキャラクターは描画のためにGPUにセットする対象から外されます。カメラに写らないのに、描画するための計算をするのは無駄だよね、ということです。
そのため、小さくしすぎると、いわば「キャラクターがカメラから見切れるか見切れないか」の隅っこぎりぎりにいるときに描画対象から外れ、パっと消えてしまうという感じになります。中心はカメラから外れてるけど、手だけは見える、ということもなくなってしまいます。
コレを避けるために、アタリ判定の球体とgeometry.BoundingSphere は、別で持たせる方がよい場合が多いです。BoundingSphereの元となっている Sphere オブジェクトは、中心点(Vector3)と半径(float)の2つの引数だけで作れますので、さくっと作ってしまうのが良いでしょう。

//初期化&作成(1回だけ行えばよいもの
if(charaMesh.geometry.BoundingSphere == null){ charaMesh.geometry.computeBoundingSphere();} //

//アタリ判定球を別途定義
var HitBall = new Sphere(charaMesh.geometry.BoundingSphere.center, charaMesh.geometry.BoundingSphere.radius);
HitBall.radius *= 0.5;   //けっこー小さめに
//判定球をモデルオブジェクトの「子」として追加
charaMesh.add(HitBall);
charaMesh.HitBall = HitBall;  //アクセスを容易にしとく
//ここは毎フレーム行う
 if(charaMesh.HitBall.intersectsSphere(tamaMesh.geometry.BoundingSphere)
{
  console.log('死んだ!');
}

☆キャラが「本当に」カメラから見えているか見えていないかを知る、良い?方法

 上のトピックで「視野判定」なるものが出ましたが、ココはちょっと違う話。
「視野判定」は、【カメラの見える範囲内】にそのキャラクターがいるかどうかの判定でしたが、実際に「見えている」となると、ちょっと話が変わってくるということにお気づきでしょうか。
 そう、「壁」や「地面の裏側」の話です。
スクリーン座標のトピックの時に書きましたが、「壁の裏側に隠れているのに、HPとか情報が表示されててバレバレ」なのは、ゲームによってはがっかりしてしまうことでしょう。コレを防ぐにはどうするか、のアプローチです。

アプローチの一例を挙げると、
1.カメラから、敵の中心点までの距離を測定
2.カメラ位置から敵の位置までのベクトルを取得し、正規化
3.カメラ位置から敵の位置まで、地面を対象とした「Raycaster」を飛ばし、衝突点を検出。
4.「3」で取得した検出点までの距離が、「1」で取得した距離より近ければ、「壁」のほうが敵の手前にある!ということ

という形になります。
そのとーり、既に紹介済みの「Raycaster.intersectObject」で、なんとかなってしまいます。
ココではソースの紹介は省略します。挑戦してみましょう。

☆「空」を表示させる方法(SkyBoxの表現のために)

Three.jsのサンプルには、非常にキレイな空のサンプルがありますが、残念ながら雲一つ無い空しか出来ません。
ゲームでは、「Skybox」や「Skydome」と呼ばれる、めっっちゃくちゃ大きい球体や立方体の【内側】に、空のテクスチャを貼り付けて表現したりします。

さてさて。また「視野判定」の話になりますが、カメラから外れている物体を表示しないのはあたりまえですが、「カメラの正面にいるけど、表示する必要がない」ものがある、ということを、考えたことはあるでしょうか。
・・そう、「遠くにあって、めっちゃ小さいっていうか見えない」ものは、表示する必要=計算をさせる必要ってありませんよね。
コレ実は、 PerspectiveCamera の宣言時の引数時にパラメータの設定があり、「カメラからこの距離以上のものは描画しない」という設定があったのです。 PerspectiveCamera の3番目と4番目の引数です。この設定は、よく「クリップ」と呼ばれます。3番目は「近すぎる場合に表示しない(ニアクリップ)」、4番目は「遠すぎる場合に表示しない(ファークリップ)」となります。

//    1 がニアクリップ、 2500 がファークリップ
camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 1, 2500);

前項で「BoundingShpereによる視野判定」がありましたが、アレは「オブジェクト全部が」表示されなくなりますが、この「クリップ」に関しては、メッシュのポリゴン1枚単位(というか1頂点毎)で表示する/しないの判定が行われます。
facrip.gif

こんな感じで、なんか「遠くの方の山がメリメリ欠けてから成形されていくように見える」のは、そのせいですね。メッシュは地面として1つなのですが、カメラからの遠さ判定が1頂点ずつ行われているため、「高い」場所は、近づかないと見えてこないとかいうことになります
・・さて。
SkyBoxの話に戻りますが。「サイズをめっちゃ大きくすればよい」という話をしましたが、そのカリング設定のおかげで、大きくしすぎると、今度は「あ、こいつ遠すぎるから表示しなくていいな」という判断がされてしまいます。
空は、山より遠くにあります。では、山より遠くにある空を表示するには、どうしたらよいでしょう。

いろいろ調べた結果、「シーンとカメラを2つ用意する」とする方法しか、なさそうです。でも、それで出来るんです。
ポイントは
1.カメラとシーンを2つずつ用意
2.背景用のカメラには、ファークリップはめちゃくちゃ大きい数をセット
3.レンダラーの「自動クリア」を無効にする
の3点です。

//宣言部分
camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 1, 2500);    //これがメイン用
camera_back = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 1, 250000);  //これが背景用

//シーンも2つ
scene = new THREE.Scene();
scene_back = new THREE.Scene();

//レンダラーは1つ
renderer = new THREE.WebGLRenderer();
renderer.autoClear = false;    //【重要!】自動クリアを無効にする!

//「遠くでも見える」ものは、scene_backにaddしていく
scene_back.add(sky_mesh);

//遠くの場合は表示しなくてもいいもの(ファークリップが効いてほしいもの)は、普通のsceneに
scene_back.add(zimen_mesh);
scene.add(hito_mesh);
scene.add(teki_mesh);

そして毎フレーム走る描画が下のような感じに。

renderer.clear(true, true, true);          //クリアは明示的に行う。
renderer.render(scene_back, camera_back);  //後ろに来るもの=背景用のモノを先に描画
renderer.render(scene, camera);

「自動クリアを無効」にしないと、renderer.render が実行された時点で自動的にそれまでの描画結果が破棄されます。つまり、2回目のrenderが走った時点で、1回目の結果が消えてしまいます。そうさせないための自動クリア無効です。
当然ながら、視点の移動や回転などは、両方のカメラに対して行う必要があるので、面倒は2倍になります。うまいことメソッドを用意して逃げましょう。

まとめに

100%独断で、なるべく出番が多そうなモノをピックアップしたつもりですが、いかがだったでしょうか。
バラバラに散らばった知識が、のちのち1つに繋がり、何かが出来上がる。そんな体験の一つの中に、ここでの知識があったら幸いです。

あと、最後にコレだけ伝えて、お別れの言葉とさせていただきます。

3Dゲーム作りたかったら、おとなしくU●ityのほうがいいよ!