LoginSignup
123
107

More than 5 years have passed since last update.

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

Last updated at Posted at 2016-12-13

まずはじめに

お前は誰だ

 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のほうがいいよ!

123
107
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
123
107