2019年11月29日(金)~11月30日(土)にかけて行われた開発合宿についてレポートします! 開発合宿とは、チーム毎に1つのプロダクトを開発し結果を競い合う短期集中型の合宿です。 今回は例年より若手からの参加が多く、非常にエネルギッシュな合宿となりました。
あいさつ
minagawa)
皆様こんにちは。コンサルティングセールス2部の皆川と申します。
最近部署を異動したばかりなのでまだまだ勉強中ですが、普段はEC業界の広告運用や提案を主に行っております。
開発合宿は基本的にエンジニア中心のイベントとなりますが、今回は作りたいものがありこの合宿に参加させて頂きました!
どうぞよろしくお願いいたします。
合宿テーマ
minagawa)
さて今回の合宿テーマは、「創造力」~"つくる"、"生み出す"喜びを力に変えろ〜です。成果発表会後の投票の審査基準は 「創造力(オリジナリティ)を感じるか」 になります。
今回も例年同様、優勝チームには海外研修(Google I/OまたはAWS re:Invent)の参加権が与えられます!景品が景品だけに、いつものHackathon以上に本気のメンバーを見る事が出来ました!
合宿当日
★1日目
☑ 8時40分 浅草駅集合
minagawa)
出発時間に全員間に合い、「奇跡」との歓声があがります。
エンジニアの働き方は基本的に裁量労働制の為、ほとんどの人が普段はゆったりとした朝を迎えております。
電車の中から開発をするメンバーもちらほら。
☑ 合宿場@鬼怒川に到着!
minagawa)
鬼怒川駅に到着しました!
これから2日間の合宿は本当に楽しみです!
合宿場に到着すると、いよいよオリエンテーションが始まりました! 各チームリーダーから気合の入った意気込みが発表され、勝負に拍車がかかりました。
☑ もくもくタイム
minagawa)
早速開発に取り掛かります。
もくもくと開発を進めるチーム、まずは全員で話し合うチームなど、それぞれチームの色が伺えました。
☑ 夕食
minagawa)
夕食は旅館のバイキングにて、みんなで和気あいあいと楽しみました。
日本酒もバイキング形式で、日光産のお酒もおいしく頂きました!
☑ 夜のもくもく
minagawa)
夜も開発は続きます、、!
★2日目
☑ 成果発表
minagawa)
各チーム、この2日間の成果をプレゼンしました。
意見や感想をSlackで交換したり、デモを見せたりと、終始アットホームな雰囲気で盛り上がりを見せました。
☑ 結果発表
minagawa)
優勝したチームは…「VRするぞ委員会」でした!!
VR空間上でスライドを投影してプレゼンできるプロダクト「Virtual LT」開発に取り組みました!
優勝メンバーより
hey_cube)
こんにちは、チーム「VR するぞ委員会」リーダー(先の画像右)の @hey_cube です。
それでは、今回作成した Virtual LT の紹介をしていきます。
プロダクトの目的・動機
hey_cube)
元々 VR 開発に興味があったため、この機会に挑戦したいというのがまずありました。
そこで色々考えたのですが、例えば勉強会を VR 空間内で出来たら楽しいのではないかと思い、今回のプロダクトを開発するに至りました。
VRChat や cluster、Virtual Cast など、VR 空間内でプレゼンできるようなプロダクトは既にありますが、Virtual LT はそれらが対応していない Oculus Go で動く点がミソです。
プロダクトの概要
hey_cube)
Virtual LT は、VR 空間上でプレゼンが出来るプロダクト(っぽいただのハリボテ)です。
VR 空間内には床、スライドを写すスクリーン、登壇者*1、観客が配置されます。
コントローラーの入力に応じてスライドが進んだり、観客がマサカリを投げたりします。
推しポイントは以下の通りです。
今回の開発合宿のテーマである「創造力」を発揮したポイントは以下の通りです。
技術的な話
hey_cube)
ここでは実装の詳細について説明します。
変な内容があったら Twitter の DM でこっそり教えてください。
まずざっくり概観ですが、Virtual LT は three.js と three-vrm を使って、 VRM 形式のモデルなどを VR 空間上に配置し、それを Oculus Go で操作できるようにしたアプリです。
それではコードの解説をします。
シーンやカメラなどの基本的な要素は割愛します。
なお、限られた時間で実装したため、冗長だったり可読性が低かったりする箇所がいくつかありますが、その辺はご了承ください。
まずはスライドを写すスクリーンです。
スクリーン自体は three.js の BoxGeometry
を平べったくしたものを配置してるだけです。
そこに貼り付けるスライドの画像は先んじて MeshBasicMaterial
に変換しています。
スライドの画像は virtual-lt/dist/images/slide${page_number}.png
というパスで予め仕込んでおく必要があります。
(本当は pptx 形式のスライドをアップロードできるようにしたかったけど時間が足りませんでした。)
import { BoxGeometry, ImageUtils, Mesh, MeshBasicMaterial } from "three"; // screen const slideLength = 6; const slidePages = Array.from( Array(slideLength), (v, k) => new MeshBasicMaterial({ color: 0xccccff, map: ImageUtils.loadTexture( `./images/slide${k}.png`, undefined, function() { renderer.render(scene, camera); } ) }) ); const screenGeometry = new BoxGeometry(4, 3, 0.2); const screenMesh = new Mesh(screenGeometry, slidePages[0]); screenMesh.position.set(0, 1.6, -3); screenMesh.rotation.set(0, Math.PI, 0); scene.add(screenMesh);
続いて登壇者となる VRM モデルの読み込みです。
VRM の読み込みには GLTFLoader
を使用します(なんでこいつは examples/
なんてディレクトリ配下にあるんでしょうね・・・)。
VRM.from()
で Promise<VRM>
に変換し、そこからレンダリングする位置やモデルのポーズを指定します。
VRMSchema.HumanoidBoneName.xxxxx
でボーンを回転させたり、 VRMSchema.BlendShapePresetName.xxxxx
で表情を弄ったりできます。
また、この登壇者モデルにはスライドを指し示すためのレーザーポインターっぽいものを付け加えています。
ConeGeometry
で細い三角錐を作り、それを右手に追加するだけで実現しています。
この開発を通して、VRM はポーズや表情などを非常に直感的に操作できる印象を受けました。
TypeScript なら補完機能が効くので、それっぽいキーワードを入力してどう補完されるか試してみたり、定義元にジャンプしてインターフェースを眺めたりできたのもかなり便利でした。
Optional Chaining もあるので、null や undefined とのユニオン型も怖くありません。
import { VRM, VRMSchema } from "@pixiv/three-vrm"; import { ConeGeometry, Mesh, MeshBasicMaterial } from "three"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader"; // speaker let speaker: VRM; const gltfLoader = new GLTFLoader(); gltfLoader.load("./models/speaker.vrm", gltf => { VRM.from(gltf).then(vrm => { scene.add(vrm.scene); speaker = vrm; vrm.scene.rotation.y = (Math.PI * 3) / 4; vrm.scene.position.x += 1.8; vrm.scene.position.z -= 1.9; vrm.humanoid ?.getBoneNode(VRMSchema.HumanoidBoneName.LeftUpperArm) ?.rotateZ(1); // ドヤ顔っぽくする vrm.blendShapeProxy?.setValue(VRMSchema.BlendShapePresetName.Angry, 0.6); vrm.blendShapeProxy?.setValue(VRMSchema.BlendShapePresetName.A, 1); vrm.blendShapeProxy?.update(); const laserPointer = ((): Mesh => { const geo = new ConeGeometry(0.01, 4, 10); const mat = new MeshBasicMaterial({ color: 0x99ff99 }); const mesh = new Mesh(geo, mat); mesh.position.x += 2.5; mesh.rotation.z -= Math.PI / 2; return mesh; })(); vrm.humanoid ?.getBoneNode(VRMSchema.HumanoidBoneName.RightUpperArm) ?.add(laserPointer); }); });
今度は観客が投げるためのマサカリを読み込みます。
著作権的に大丈夫そうなやつを適当に拾ってきて使いました。
(その拾ってきたファイルが実は壊れてて、1 時間ほど試行錯誤した後にそれに気づいて、MTL ファイルを手動で直したのはまた別のお話です。)
こっちは OBJ 形式のファイルだったので、 OBJLoader
と MTLLoader
を使用しました。
本当はコントローラーから入力があるまで描画しなくて良いんですが、最初に描画させておくことでブラウザがクラッシュしにくくなったのでそのようにしました(え?)。
import { Group } from "three"; import { MTLLoader } from "three/examples/jsm/loaders/MTLLoader"; import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader"; // axe let axe: Group; const mtlLoader = new MTLLoader(); mtlLoader.load("./models/axe/axe.mtl", materials => { materials.preload(); const objLoader = new OBJLoader(); objLoader.setMaterials(materials); objLoader.load("./models/axe/axe.obj", object => { object.scale.set(0.05, 0.05, 0.05); // 足元に描画しておくことでテクスチャをバッファリング? object.position.set(0, 0, 1.7); scene.add(object); axe = object; }); });
さて、いよいよアニメーションさせる部分です。
・・・なんですが、ここは体系立てて説明できるほどコードが整理されていないので、かいつまんで紹介します。
まず、HMD と登壇者モデルの首の動きを連動させる部分です。
three.js が提供している WebGLRenderer#vr.getDevice()
で HMD を取得し、更に VRPose#getPose().orientation
でその HMD の回転を取得します。
そしてそれを登壇者モデルの首ボーンに反映させています。
import { VRMSchema } from "@pixiv/three-vrm"; import { Euler } from "three"; const deviceOrientation = renderer.vr.getDevice()?.getPose().orientation; if (deviceOrientation) { // HMD と登壇者モデルの首を同期 const neck = speaker.humanoid?.getBoneNode(VRMSchema.HumanoidBoneName.Neck); neck?.setRotationFromEuler( new Euler( deviceOrientation[0] * 2, deviceOrientation[1] * -2, deviceOrientation[2] * -2 ) ); }
次に登壇者モデルのランダムなまばたきです。
簡単なコードですが、こういう些細な動きでモデルの存在感がグッと増します。
import { VRMSchema } from "@pixiv/three-vrm"; // まばたき speaker.blendShapeProxy?.setValue( VRMSchema.BlendShapePresetName.Blink, Math.random() < 0.05 ? 1 : 0 );
次がコントローラーのポーリングです。
「え、コントローラーの入力ポーリングするの!?」と思われた方もいらっしゃるでしょう。我々も思いました。
当然 Oculus Go のコントローラーを取得する API がどこかにあって、そこにイベントリスナーを追加できるんだろうと思っていました。
が、限られた時間の中で調べた範囲ではそういったサンプルが見つけられず...
代わりに Gamepad API から取得した値をポーリングする手法を見つけたので、それを採用した結果こうなりました。
もっと上手いやり方をご存知の方がいたら教えてください...
ちなみにこの Gamepad API が提供している navigator.getGamepads()
の戻り値ですが、TypeScript 3.7.2 の型定義上では (Gamepad | null)[]
となっています。
が、実際に叩いてみると length
というプロパティが生えたただのオブジェクトが返ってくる場合があるようでした。
よって、型定義に従って Array#find()
などを呼び出そうとすると場合によっては実行時エラーになってしまうようです。
ここで困ったのが、コントローラーのボタンが 2 つ(タッチパッドのスクロールは上手く取得できませんでした)なのに対して、実現したいアクションが 3 種類以上あったことです。
色々試してみた結果、今回はボタン同時押しなどを駆使することでなんとか解決しました。
なお、登壇者の右腕(レーザーポインターを持っている側の腕)とコントローラーの同期もここで行なっています。
import { VRMSchema } from "@pixiv/three-vrm"; import { Euler } from "three"; const gamePads = navigator.getGamepads(); // PC ブラウザで動かすと gamePads に find が生えていない if (gamePads.find) { const gp = gamePads.find(gp => gp?.id == "Oculus Go Controller"); const controllerOrientation = gp?.pose?.orientation; if (controllerOrientation) { // controller と登壇者モデルの腕を同期 const rightUpperArm = speaker.humanoid?.getBoneNode( VRMSchema.HumanoidBoneName.RightUpperArm ); rightUpperArm?.setRotationFromEuler( new Euler( 0, controllerOrientation[1] * -1.5, controllerOrientation[0] * 2 ) ); } // コントローラーの入力をポーリング if (gp) { // 複雑なので割愛 } }
コントローラーの入力に応じて実行する moveToSpeakerPos()
と moveToListenerPos()
もご紹介します。
HMD の視点を登壇者側 or 観客側に切り替える関数なのですが、HMD の視点を動かす方法がわからなかったため、VR 空間そのものを動かすことで視点移動を実現しました。
(VR 空間での視点移動のスマートなやり方も募集しています...)
const moveToSpeakerPos = (): void => { listener.scene.visible = true; speaker.scene.visible = false; scene.position.set(0, 0, -1.8 * Math.SQRT2); scene.rotation.set(0, (-Math.PI * 3) / 4, 0); position = "speaker"; }; const moveToListenerPos = (): void => { listener.scene.visible = false; speaker.scene.visible = true; scene.position.set(0.1, 0, -2); scene.rotation.set(0, 0, 0); position = "listener"; };
以上が我々の実装のほぼ全てです。
右も左もわからない状態で臨んだ VR 開発でしたが、当初予定していた機能は一通り実装できました。
開発の流れ
hey_cube)
プロジェクトを立ち上げるに当たって、まず考えるのは使用する技術です。
最初は Unity or Web で悩みました。
VR コンテンツを作る上で Unity は有力な候補ですが、書き慣れている TypeScript で開発がしたかったのと、普段開発している Web アプリの新しい可能性を見てみたかったため Web を使って実現することにしました。
Web で VR を実現する手段もいくつかあります。
TypeScript フレンドリーだと聞いている Babylon.js も考えたのですが、three-vrm が使いたかったため three.js にしました。
次にリポジトリの準備です。
最初に自分が ESLint、Prettier、TypeScript、webpack、package.json、VSCode の設定をまとめて作成し、後は src/index.ts
を編集して yarn start
すれば動くようなリポジトリを作りました。
当日やることがわからない状態でスタートしないよう、思いつく限りの issue も用意しておきました。
加えて、デモができるような状態にもしておく必要があります。
開発合宿の最終日には成果発表があるのですが、そこで参加者全員にいちいち Oculus Go を被らせる訳にも行きません。
そこで今回は Vysor を使って MacBook にミラーリングし、その様子をプロジェクターで投影することにしました。
画質も欲しかったので 1 ヶ月分だけ課金もしました。
行きの電車の中でお弁当を食べつつ開発環境をセットアップしたり開発目標についてチームで議論したりしました。
VR 開発に明るくないチームであることに加え、Web での VR 自体まだ早熟な技術であることを踏まえ、実装目標は最低限のものだけに絞りました(この判断は結果的に大正解でした)。
合宿中は、issue の中から優先度が高く簡単そうなものから 1 人 1 つずつアサインし、それぞれ黙々と作業をする感じでした。
観客モデルを 4 体配置したら重すぎてブラウザが動かなくなったり、コントローラーの入力を取る方法が分からなくて四苦八苦したりもしました。
マイクからの入力を取得する方法がわからなかったため、口パク機能(マイクが何らかの音を拾ったら登壇者モデルの口を適当に動かす)を諦めたりもしました。
成果発表では、自分がマイクを握り、もう一人のメンバーに Oculus Go を被ってアプリを動かしてもらうという形を採りました。
当初の予定通り Vysor を使って Oculus Go の映像をミラーリングしつつ、発表資料の一部を Virtual LT 内で見せるというやり方にしましたが、中々面白がってもらえたように思います。
投票の結果、ありがたいことに我がチームが優勝と相成りました。
開発の進行を振り返って
hey_cube)
振り返ってみると、事前準備の段階からかなり上手く進められたなという印象でした。
コードがすぐに書き始められるリポジトリの用意や、issue の整理などの準備が功を奏しました。
また、発表時にどう見せるかを初期段階から考えられていたことも良い点だったと思われます。
そして何より、実装目標のスコープの設定がかなり絶妙であったため、開発中に不要に焦ったり、計画の見直しに手間取ったり、発表準備まで手が回らなかったりなどが発生しなかったことが最大の勝因だと感じています。
普段の開発計画もこんな感じで立てられればなぁ...
☑ 感想
minagawa)
優勝者のお2人から、それぞれコメントを頂きました。
使用する技術選定からデバイス選択まで全てを1から行う事がとても難しかったです。ですが、もともとやりたいと思っていたVR開発が出来たので楽しく開発出来ました。開発合宿は、「興味はあるけど普段触っていないものを触る口実」を作ることが出来るという側面があると思います。今回はWebVRを使う口実ができとても良い2日間となりました。
VR開発はまだ先駆者が少なく情報が十分でないので可能/不可能の判断がつきにくい部分があり苦労しました。VRプログラミングはおそらく自分1人ではやらなかったと思うので、この機会に触れる事が出来て良かったです。
最後に
参加者感想
minagawa)
メンバー全員がそれぞれの役割を全うし学びのあった2日間だったのではないかと思います。
最後に各メンバーからのひとことを紹介します。
1から全て開発する経験ができたのが良かったです
短い時間で集中してプロダクトを作る経験が楽しかったので、合宿や業務以外でももっと開発をしてみようと思うきっかけになりました。
開発自体は当初の目標に届かず、やや残念でしたが、 普段の業務で使っているものとは異なる技術スタックに触れることができたのが新鮮で楽しかったです。色々勉強したいことも増えました。
中途採用について
Opt Technologiesでは一緒に働くメンバーを募集しております! 興味のある方は、こちらから「カジュアル面談希望」と添えてご応募ください!
*1:登壇者用のモデルにはニコニ立体ちゃんをお借りしました。
*2:開発合宿を行った2019年11月時点では Firefox Reality で確かに動かせたはずなんですが、何故か今日(2020年1月24日)は動きませんでした...