このようなWebサービスを公開しています。ゲーム用のMODに使うjsonを作成するという地味なものですが、自分の中でなかなかフロントエンド的な部分と格闘することになったのでメモ。
当初はクエリパラメータを受け取って、それを元に生成されたjsonの文字列を返すだけ、という極めて無味乾燥なもので、UIとしても最悪なものでした。とは言っても、使うのは自分か、極めて狭い世界の知り合いだけなので、最低それでもサービス(手間の削減)として成り立つのです。とはいえ、さすがに改良した方がいいと思い、
- フォームでパラメータを送信
- プレビューをつける
という2つの対策を行うことにしました。この内、前者の「フォームでパラメータを送信」については、ザ・html初心者の練習課題みたいなやつで、ぐぐれば参考記事が無限に出てくるようなものなのです。とは言っても、こういうWebアプリGUIみたいのを真正面から実装するのは(そしてそれをDjangoのviewsを通してやり取りするのは)始めてだったので、それなりに調べてそれなりに楽しくやれました。
お絵描き
問題は後者のプレビューの方です。実はVRのゲームなので、3Dのプレビューがないとあまり意味がないのです。とは言っても、htmlで3D表示なんてできるのか......? と思っていたので、最初に思いついたのは、二面図で立体を表現することでした。まだこの時はjavacriptとかを調べるのがめんどくさくて、全部CSSでできないかな~と考えていましたが、結論的には無理そうでした。変態的にCSS使いの上手い人なら別かもしれないですが、今回はそういう沼の話ではないです。
端的に言うと、ある点Aから半径rの円周上を●が回転して......というアニメーションが必要でした。javascriptを使ってキャンバスに描画していくしかないと考え、サンプルコードを調べて、アニメーションの設定の仕方を調べて完成させました。出来上がった結果がこちら。
上のは静止画ですが、実際のWebサイトでは、赤丸を中心に黒丸が回転しています。書いたjavascriptはこんな感じ。
<script>
var start = new Date();
var sec = 0;
var now = 0;
var datet = 0;
WIDTH = 500;
HEIGHT = 500;
canvas = document.createElement('canvas');
canvas.width = WIDTH;
canvas.height = HEIGHT;
canvas.style.border = "1px solid";
var ctx = canvas.getContext('2d');
document.body.appendChild(canvas);
canvas2 = document.createElement('canvas');
canvas2.width = 500;
canvas2.height = 500;
canvas2.style.border = "1px solid";
var ctx2 = canvas2.getContext('2d');
document.body.appendChild(canvas2);
function loop(timestamp) {
var now = new Date();
var sec = (now.getTime() - start)/1000;
var theta = sec*2*Math.PI/{{par.t}};
var radius = 100*{{par.r}};
var offset = 100*{{par.o}};
var height = 100*{{par.h}};
//init
ctx.clearRect(0, 0, WIDTH, HEIGHT);
//stage
ctx.fillStyle = 'lightblue';
ctx.fillRect( 50, 400, 400, 100)
//humanbody
ctx.fillStyle = 'gray';
ctx.fillRect( 225, 270, 50, 130)
//humanhead
ctx.beginPath();
ctx.arc(250, 250, 20, 0, 2*Math.PI);
ctx.fillStyle = 'gray';
ctx.fill();
//cameratarget
ctx.beginPath();
ctx.fillStyle= 'red';
ctx.arc(250, 400-100*{{par.a}}, 10, 0, 2*Math.PI);
ctx.fill();
ctx.strokeStyle = 'black';
ctx.stroke();
//camera
ctx.beginPath();
ctx.fillStyle = 'black';
ctx.arc(250+radius*Math.sin(theta), 400-height, 10, 0, 2*Math.PI);
ctx.fill();
//init2
ctx2.clearRect(0, 0, WIDTH, HEIGHT);
//stage2
ctx2.fillStyle = 'lightblue';
ctx2.fillRect(50,150,400,200);
//human2
ctx2.fillStyle = 'gray';
ctx2.fillRect(225,225,50,50);
ctx2.strokeStyle = 'black';
ctx2.strokeRect(225,225,50,50);
//camera2
ctx2.beginPath();
ctx2.fillStyle = 'black';
ctx2.arc(250+radius*Math.sin(theta), 250+offset+radius*Math.cos(theta), 10, 0, 2*Math.PI);
ctx2.fill();
//cameratarget2
ctx2.beginPath();
ctx2.fillStyle= 'red';
ctx2.arc(250, 250+offset, 10, 0, 2*Math.PI);
ctx2.fill();
ctx2.strokeStyle = 'black';
ctx2.stroke();
//forward
ctx2.fillStyle = 'darkgray'
ctx2.font = "bold 30px 'MS gothic'"
ctx2.fillText('前方', 225, 450);
// requestAnimationFrameを呼び出す。
// requestAnimationFrameは1度の呼び出しで1回しか実行してくれないため
// 毎回呼び出す必要がある。
window.requestAnimationFrame((ts) => loop(ts));
}
// requestAnimationFrameを1回だけ呼び出す。
// あとはloop関数の中でrequestAnimationFrameが呼び出され
// その中でloop関数が実行され、そのloop関数の中でrequestAnimationFrameが…
// となるので永遠にアニメーションが続く。
window.requestAnimationFrame((ts) => loop(ts));
</script>
基礎となるコードはここのものをお借りしました。ありがとうございます。
例えば自分が数年前に書いたPythonのコードとか、今見返すと不格好で恥ずかしくなるのですが、これも数年後に同じような感想を抱くのでしょうか。さすがにjavascriptはそうそう触らないかな......。
Three.jsの導入
最初は3D表示なんてとんでもないと思っていましたが、htmlやjavascriptを触っている内に「Three.jsとやらを使えばいけるのでは......?」という思いが芽生えてきました。身近な所では、レイマーチングのすごい例で使われてるのを見たぐらいですが、それより遥かに簡単なプリミティブによる図形による描画も当然できるはずです。ics.mediaさんの解説ページ(神)を見てみると、私が普段やっているBlenderのスクリプト描画に極めて近い処理をやっていることに気付いたので、これはいけそう。
という訳で、作ったプレビューがこちら。
当然、実際のサイトでは、この画像がカメラの位置に合わせてリアルタイムで変化しています。書いたjavascriptはこんな感じ。
<script>
// ページの読み込みを待つ
window.addEventListener('load', init);
function init() {
// サイズを指定
const width = 960;
const height = 540;
// レンダラーを作成
const renderer = new THREE.WebGLRenderer({
canvas: document.querySelector('#myCanvas')
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(width, height);
// シーンを作成
const scene = new THREE.Scene();
// light
const light = new THREE.DirectionalLight(0xFFFFFF, 1);
scene.add(light);
const light2 = new THREE.AmbientLight(0xFFFFFF, .2);
scene.add(light2);
// カメラを作成
const camera = new THREE.PerspectiveCamera(65, width / height,1,20000);
camera.position.set(0, {{par.h}}*100, {{par.r}}*100);
camera.lookAt(new THREE.Vector3(0,{{par.a}}*100, 0));
//グループ?
const group = new THREE.Group();
scene.add(group);
group.add(camera);
group.position.z += {{par.o}}*100;
// 箱を作成
const geometry = new THREE.BoxGeometry(400, 400, 200);
const material = new THREE.MeshLambertMaterial({
color: 0x6699FF
});
const box = new THREE.Mesh(geometry, material);
box.position.set(0, -200, 0);
scene.add(box);
const geometry2 = new THREE.BoxGeometry(40, 130, 40);
const material2 = new THREE.MeshLambertMaterial({
color: 0xaaaaaa
});
const box2 = new THREE.Mesh(geometry2, material2);
box2.position.set(0, 65, 0);
scene.add(box2);
const geometry3 = new THREE.SphereGeometry(25, 32, 32);
const sphere = new THREE.Mesh(geometry3, material2);
sphere.position.set(0, 155, 0);
scene.add(sphere);
const geometry4 = new THREE.CylinderGeometry(5, 5, 100, 8);
const material4 = new THREE.MeshBasicMaterial({
color: 0x0000ff
});
const saber1 = new THREE.Mesh(geometry4, material4);
saber1.position.set(-50, 100, 20);
saber1.rotation.x = 10;
scene.add(saber1);
const material5 = new THREE.MeshBasicMaterial({
color: 0xff0000
});
const saber2 = new THREE.Mesh(geometry4, material5);
saber2.position.set(50, 100, 20);
saber2.rotation.x = 10;
scene.add(saber2);
const geometry6 = new THREE.BoxGeometry(250,400,2000);
const material6 = new THREE.MeshLambertMaterial({
color : 0x444444
});
const lane = new THREE.Mesh(geometry6, material6)
lane.position.set(0,-200,1200);
scene.add(lane);
tick();
// 毎フレーム時に実行されるループイベントです
function tick() {
group.rotation.y += 6.28 / 60 / {{par.t}};
renderer.render(scene, camera); // レンダリング
requestAnimationFrame(tick);
}
}
</script>
大元のコードは上に挙げたics.mediaさんの神サイトのものをお借りしました。二面図では四角と丸で表現していたオブジェクトをジオメトリに落とし込む必要がありますが、モデリングに慣れている私にとってはCSSのお絵描きと何ら変わりません(Blenderと座標系が違うのは少し戸惑いますが)。また、カメラの位置は三角関数等で直接記述するのではなく、中心に親オブジェクトを据えてそれを回転させることで効率的に記述しています。
何はともあれこれで、Webサイトの中で直感的な3Dプレビューを見せるという目的が果たせました。Django-htmlでjavascript中のパラメータ(上のコードで{{hoge}}
になっている部分)を可変的にいじることができるので、プレビューの見栄えもそれに応じて変化させることができます。
更なる機能
機能としてはここで終わらせるつもりだったのですが、Three.jsに触れる中でマウスコントロールでプレビューを操作するライブラリがあったので、それを使って新しい機能が実装できないかと考えました。本来このWebサービスは、ゲームのMODに使うjsonを生成するためのものです。基本的にはパラメータがあって、jsonにそれを入れ込んで、それがゲームの見栄え(具体的にはカメラの位置・角度)として反映される、という流れです。
しかし、「とりあえずパラメータを入れてみてから画角を確認」という手順ではなく「画角を調節したらパラメータがわかる」という方が、こだわり派のプレイヤーの感覚としてストレスがないと思われるので、そのような機能を実装してみました。
こんな感じです。
本来であれば、マウスでインタラクティブにキャンバスを操作するには、javascriptでそれなりの格闘が必要と思われますが、幸いにもThree.jsはOrbitControls.js
というファイルがその辺りの機能をカバーしてくれています。
なお、最初にサンプルを入れた時にはキャンバス外のマウス操作にもキャンバスが反応し、他の操作(ボタンのクリックとか)が一切できなくなるというパニックに陥りましたが、冷静にサンプルページを読んでみると、操作をキャンバス内に限定する方法も書いてあります。(THREE.OrbitControls()
の第二引数にcanvasElement
を入れます)。
オイラー角の反乱
段々Webの話を離れて3Dの専門的な話になっていきますが、我々が日常的に使う3次元の回転はオイラー角というパラメータで指定されるのが普通です。これはx軸に何度、y軸に何度、z軸に何度という風に指定される角度のことです。3Dに親しみのない方は、逆にそれ以外にどうやって角度を表現するのかと思われるかもしれませんが、これは実は結構厄介な代物です。
と言うのも、角度の表現が一意でないのです。
これはUnityで作ったわかりやすい例です。上の図のRotationに注目してもらいたいのですが、X軸とY軸とZ軸を180度回転させたものと、全く回転させてないものとでは、見かけ上違いは現れません。実際にThree.jsのOrbitControlsがどのような値を返すかというと。
デフォルトではこのようになります。真正面を向いているので、全ての角度が0になって欲しいのですが、ややこしい方の表現になっています。「表現がややこしいだけで、最終的に同じ角度になるんだったら、どっちでもよいんじゃないの?」と思った人は甘いです。確かにこの時は一致しますが、実はこの状態で返される角度のほとんどはUnity上で一致しません。オイラー角はどの軸をかけるかの順番によって結果が変わってしまうので、この順番が違うと復元が不可能になるのです。
あと、オイラー角にはジンバルロックという困った減少がありますが、本記事で詳細は述べません。
ちなみに、3Dゲーム等の世界では、このような困った数値が困ったことがないように、回転を一意に指定する四元数(クォータニオン)という数字が内部処理が使われています。じゃあそのクォータニオンを渡せよと言いたくなりますが、今この状況でWebGL(Three.jsはWebGLのラッパーです)とUnityの橋渡しをしているのは、オイラー角という入力形式が定められたjsonですので、それに合わせなければいけません。
座標系と順番
結論的から言うと、Three.js上で、どの順番によるオイラー角かを指定できます。このサイトが参考になりました。デフォルトではXYZ
の順番になっているので、これをUnity(ZXY
順)に合わせます。後に載せるコードではcamera.rotation.order = "ZXY"
となっているところです。これで上の問題は解決します。
実はややこしいので今まで黙っていましたが、WebGL
とUnity
では座標系が違います。WebGLは右手系y-upで、Unityは左手系y-upです。どういうことかというと、両者とも高さ方向がy(上が正)で、前後方向がz(奥が正)ということは変わらないのですが、xの扱いが違うのです。WebGLでは左手が伸びる方向がxの正ですが、Unityでは右手が伸びる方向がxの正です。
x,y,zの周り順が違ってしまうため、外積の正負も変わってしまいます。WebGLは右ねじで、時計回りが正の回転ですが、Unityは左ねじで、反時計周りが正の回転です。後で載せるコードでは、その違いを解消するためにマイナスをかけたりかけてなかったりします。ややこしい......。
var px = -Math.round(camera.position.x/10)/10;
var py = Math.round(camera.position.y/10)/10;
var pz = Math.round(camera.position.z/10)/10;
var rx = Math.round(camera.rotation.x*180/Math.PI);
var ry = -Math.round(camera.rotation.y*180/Math.PI)+180;
var rz = -Math.round(camera.rotation.z*180/Math.PI);
このあたり。もしWebGLや他のGL系の言語とUnityの座標変換で混乱している人がいたら、上の変換式が役に立つかも。
やや余談になりますが、Blenderはそれとはまた違うz-upの右手系を使っています。BOOTHで販売されているアバターの大半が、ボーンのペアレントを解除すると横倒しになっているのは、この座標系の違いに合わせているのが原因です。
上の角度検出システムのソースコードです。
<script>
// ページの読み込みを待つ
window.addEventListener('load', init);
function init() {
var offset_y = 0;
var offset_x = 0;
var offset_z = 0;
// サイズを指定
const width = 960;
const height = 540;
// レンダラーを作成
const canvasElement = document.querySelector('#myCanvas')
const renderer = new THREE.WebGLRenderer({
canvas: canvasElement,
});
renderer.setSize(width, height);
// シーンを作成
const scene = new THREE.Scene();
// カメラを作成
const camera = new THREE.PerspectiveCamera(65, width / height, 1, 10000);
camera.position.set(0, 450, -450);
camera.rotation.order = "ZXY"
//キー入力
window.addEventListener('keydown', keydownfunc);
function keydownfunc(event){
var key_code = event.keyCode;
if( key_code == 87 ) offset_y += 10;
if( key_code == 88 ) offset_y -= 10;
if( key_code == 65 ) offset_x += 10;
if( key_code == 68 ) offset_x -= 10;
if( key_code == 83 ) offset_z += 10;
if( key_code == 32 ) offset_z -= 10;
}
// controls
const controls = new THREE.OrbitControls(camera, canvasElement);
controls.target.y = offset_y;
controls.target.x = offset_x;
controls.target.z = offset_z;
// light
const light = new THREE.DirectionalLight(0xFFFFFF, 1);
scene.add(light);
const light2 = new THREE.AmbientLight(0xFFFFFF, .2);
scene.add(light2);
// 箱を作成
const geometry = new THREE.BoxGeometry(400, 400, 200);
const material = new THREE.MeshLambertMaterial({
color: 0x6699FF
});
const box = new THREE.Mesh(geometry, material);
box.position.set(0, -200, 0);
scene.add(box);
const geometry2 = new THREE.BoxGeometry(40, 130, 40);
const material2 = new THREE.MeshLambertMaterial({
color: 0xaaaaaa
});
const box2 = new THREE.Mesh(geometry2, material2);
box2.position.set(0, 65, 0);
scene.add(box2);
const geometry3 = new THREE.SphereGeometry(25, 32, 32);
const sphere = new THREE.Mesh(geometry3, material2);
sphere.position.set(0, 155, 0);
scene.add(sphere);
const geometry4 = new THREE.CylinderGeometry(5, 5, 100, 8);
const material4 = new THREE.MeshBasicMaterial({
color: 0x0000ff
});
const saber1 = new THREE.Mesh(geometry4, material4);
saber1.position.set(-50, 100, 20);
saber1.rotation.x = 10;
scene.add(saber1);
const material5 = new THREE.MeshBasicMaterial({
color: 0xff0000
});
const saber2 = new THREE.Mesh(geometry4, material5);
saber2.position.set(50, 100, 20);
saber2.rotation.x = 10;
scene.add(saber2);
const geometry6 = new THREE.BoxGeometry(250,400,2000);
const material6 = new THREE.MeshLambertMaterial({
color : 0x444444
});
const lane = new THREE.Mesh(geometry6, material6)
lane.position.set(0,-200,1200);
scene.add(lane);
tick();
// 毎フレーム時に実行されるループイベントです
function tick() {
controls.target.y = offset_y;
controls.target.x = offset_x;
controls.target.z = offset_z;
renderer.render(scene, camera); // レンダリング
requestAnimationFrame(tick);
var px = -Math.round(camera.position.x/10)/10;
var py = Math.round(camera.position.y/10)/10;
var pz = Math.round(camera.position.z/10)/10;
var rx = Math.round(camera.rotation.x*180/Math.PI);
var ry = -Math.round(camera.rotation.y*180/Math.PI)+180;
var rz = -Math.round(camera.rotation.z*180/Math.PI);
var ox = -Math.round(offset_x)/100;
var oy = Math.round(offset_y)/100;
var oz = Math.round(offset_z)/100;
var pos_text = '{"x":' + String(px) + ',"y":' + String(py) + ',"z":' + String(pz) + '}';
var rot_text = '{"x":' + String(rx) + ',"y":' + String(ry) + ',"z":' + String(rz) + '}';
var offset_text = 'TARGET POS : x ... ' + String(ox) + ', y ... ' + String(oy) + ', z ... ' + String(oz);
var pos_div = document.getElementById('pos_div');
var rot_div = document.getElementById('rot_div');
var offset_div = document.getElementById('offset_div');
pos_div.innerHTML = pos_text;
rot_div.innerHTML = rot_text;
offset_div.innerHTML = offset_text;
var pos_box = document.getElementById('copyTarget1');
pos_box.value = pos_text;
var pos_box = document.getElementById('copyTarget2');
pos_box.value = rot_text;
}
}
</script>
感想
Three.jsをうまく使うことができて、当初思ったよりも使い勝手のよいものができました。3DとWebをつなぐ技術としてこれからも活用していきたいと思います。javascriptは仕様が統一されていなくとっちらかっている印象ですが、教材がネット上に無限にあるのと(怪しい業者の者も多いですが)、変更がすぐ目に見えるのが良いですね。深入りはしたくないですが。
後、弊アプリも、現在はあるゲームに特化した仕様ですが、Unityを使うゲームでExternal Cameraを使うものならば、弊アプリで得られるパラメータが活用できるかもです。そうなった嬉しい。MOD間でそういう仕様が統一されたら更に嬉しい。