ブラウザ3Dゲームを学んで作って遊ぶの巻 #1 Three.jsでモデルを表示

2024年1月30日

Three.js プログラミング 学習

eyecatch 以前から計画していた3Dゲームを作る開発をようやくスタートすることができました。 とりあえず、3Dやデザインが得意な友達も手伝ってくれるとのことで、多少気が楽になっていますが、実は他人を巻き込むと「次何すればいい?」の指示マネジメントに追われてしまうので、かなりゆるく進めるつもりです。 と入っても、ダラダラと延々と開発をし続けるつもりもないので、友達の作ってくれたモデルデータをインターネットブラウザ画面に表示してみるというところからスタートしてみました。 あ、ちなみに、どんなゲームを作るかというのは、まだシークレットなので、そのうちゲーム開発が進んできたらブログで紹介していこうと思います。

友達の作ってくれた3Dモデル

ユゲタが東京に出てきたばっかりの学生の頃、色々とお互いに切磋琢磨しあった、心の友に最初にお願いしたのが、ステージの3Dモデリングでした。 とりあえず、室内で汎用性のあるモデリングをお願いしたところ、このような素敵なデータを送ってくれました。
Blenderで表示していますが、友達は、LightWaveを使ってモデリングをしたようです。 でも、テクスチャやマテリアルはBlenderでセットしてくれています。 このモデルデータを、便利な3DフォーマットのGLB形式でエクスポートして、まずは基本のオブジェクトファイルの用意は完了です。 次にこれをブラウザで表示します。

Threejsで表示

Threejsは、インターネットブラウザで3Dを表示するめちゃくちゃメジャーなライブラリです。 いろいろなコントロールなどのライブラリも充実しているので、もはやこのライブラリにおんぶにだっこ状態で開発を進めたいと思います。 (何か問題でも?) データを表示させるだけなら、htmlファイルを作って、次のソースコードを書き込めばブラウザで簡単に表示できます。 ファイル構造
- threejs_sample.html - stage.glb
threejs_sample.html <!-- three.min.js r110 --> <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/110/three.min.js"></script> <!-- DRACOLoader.js --> <script src="https://cdn.jsdelivr.net/gh/mrdoob/three.js@r110/examples/js/loaders/DRACOLoader.js"></script> <!-- GLTFLoader.js --> <script type="module" src="https://cdn.jsdelivr.net/gh/mrdoob/three.js@r110/examples/js/loaders/GLTFLoader.js"></script> <style> * { margin: 0; padding: 0; border: 0; overflow: hidden; } </style> <script type="module"> window.addEventListener("load", init); function init() { let mesh = null; let mixer = null; const clock = new THREE.Clock(); //シーンを作成 const scene = new THREE.Scene(); //カメラを作成 const camera = new THREE.PerspectiveCamera( 70, window.innerWidth/window.innerHeight, 0.1, 1000 ); camera.position.z = 5.7; camera.position.y = 0.3; //レンダラーを作成 const renderer = new THREE.WebGLRenderer(); renderer.setSize( window.innerWidth, window.innerHeight ); document.body.appendChild( renderer.domElement ); //背景色を設定 renderer.setClearColor(0x00ffff, 1); //renderer.setClearColor(0x001111, 1); //renderer.gammaOutput = true; //光源を作成 const light = new THREE.DirectionalLight("#c1582d", 1); const ambient = new THREE.AmbientLight("#85b2cd"); light.position.set( 0, -70, 100 ).normalize(); scene.add(light); scene.add(ambient); const texture = new THREE.Texture(); const manager = new THREE.LoadingManager(); manager.onProgress = function ( item, loaded, total ) {}; const onProgress = function ( xhr ) {}; const onError = function ( xhr ) {}; // GLTF 3DモデルLoader const loader = new THREE.GLTFLoader(); loader.setCrossOrigin( 'anonymous' ); // r84 以降は明示的に setCrossOrigin() を指定する必要がある loader.setDRACOLoader( new THREE.DRACOLoader() ); // Load a glTF Animation resource loader.load( // resource URL "stage.glb", // called when the resource is loaded function ( gltf ) { mesh = gltf.scene; mesh.scale.set( 0.05, 0.05, 0.05 ); //mesh.rotation.set(0, -Math.PI/2, 0); //mesh.rotation.x = Math.PI/2; mesh.rotation.y = -Math.PI/4; let animations = gltf.animations; if ( animations && animations.length ) { mixer = new THREE.AnimationMixer( mesh ); for ( let i = 0; i < animations.length; i ++ ) { let animation = animations[ i ]; mixer.clipAction( animation ).play(); } //mixer.clipAction( animations[ 0 ] ).play(); //Fly } scene.add( mesh ); }, // called when loading is in progresses function ( xhr ) { console.log( ( xhr.loaded / xhr.total * 100 ) + '% loaded' ); }, // called when loading has errors function ( error ) { console.log( 'An error happened' ); } ); render(); let isMouseDown = false; //★★★★★★★ マウス処理スタート ★★★★★★★ //マウスを押したとき document.addEventListener('mousedown', () => { isMouseDown = true; }, false); //マウスを動かした時 document.addEventListener('mousemove', () => { if (isMouseDown) { // 3DモデルをX軸とY軸方向に回転させます if ( mesh ) { mesh.rotation.y = getMouseX(event)/50; mesh.rotation.x = getMouseY(event)/50; } } }, false); //マウスを離したとき document.addEventListener('mouseup', () => { isMouseDown = false; }, false); //★★★★★★★ マウス処理エンド ★★★★★★★ //★★★★★★★ タッチ処理スタート ★★★★★★★ //タッチ開始時 document.addEventListener('touchstart', () => { isMouseDown = true; }, false); //タッチした状態で動かした時 document.addEventListener('touchmove', () => { if (isMouseDown) { // 3DモデルをX軸とY軸方向に回転させます if ( mesh ) { mesh.rotation.y = getMouseX(event)/50; mesh.rotation.x = getMouseY(event)/50; } } }, false); //タッチ終了時 document.addEventListener('touchend', () => { isMouseDown = false; }, false); //★★★★★★★ タッチ処理エンド ★★★★★★★ //毎フレーム時に実行されるループイベント function render() { requestAnimationFrame( render ); if (mixer) mixer.update(clock.getDelta()); renderer.render(scene, camera); } //リサイズイベント発生時に実行 window.addEventListener('resize', onWindowResize, false); function onWindowResize() { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize( window.innerWidth, window.innerHeight ); } } function getMouseX(event) { if (event.type.indexOf("touch") == -1) return event.clientX; else return event.touches[0].clientX; } function getMouseY(event) { if (event.type.indexOf("touch") == -1) return event.clientY; else return event.touches[0].clientY; } </script>

ブラウザで表示

表示エンジンを本気で作ってみる

第一印象が、真っ黒やんけ!と思ったあなた。ボクも同じ気持ちです。 色々と変えないとこのままではブラウザで表示する度に、ため息がでてしまいます。

改善ポイント

・オブジェクトが黒いのはアンビエント設定がちゃんとなっていないから ・コードのオブジェクト化 ・3Dモデルを固定表示ではなくて、アップロードできる方式にする ・マウスでグリグリしてモデルをも回せるようにする(上記サンプルは、モデルを回転させているだけなので)

ファイル構成

html1つだけの構成を、main.jsを基準にした機能分解してファイル分けを行いました。

ソースコード

index.html <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>sample three.js</title> <link rel="stylesheet" href="style.css"> <script type="importmap"> { "imports": { "three" : "https://unpkg.com/three@0.141.0/build/three.module.js", "GLTFLoader" : "https://unpkg.com/three@0.141.0/examples/jsm/loaders/GLTFLoader.js", "OrbitControls" : "https://unpkg.com/three@0.139.2/examples/jsm/controls/OrbitControls.js", "DRACOLoader" : "https://unpkg.com/three@0.139.2/examples/jsm/loaders/DRACOLoader.js" } } </script> <script type="module" src="main.js"></script> </head> <body> <div id="screen"></div> <button name="upload">3Dデータアップロード (glb)</button> <div class="hidden"><input type="file" name="glb" accept=".glb, .gltf, .fbx"/></div> </body> </html> style.css html,body,#screen{ margin:0; padding:0; border:0; outline:0; width:100%; height:100%; overflow:hidden; } #screen{ background-color:black; } button[name="upload"]{ position:absolute; top:10px; left:10px; padding:10px; font-size:12px; border:0; background-color:black; color:white; border-radius:5px; cursor:pointer; } button[name="upload"]:hover{ opacity:0.5; } .hidden{ display:none; } main.js import { Glb } from "./glb.js" import { Event } from "./event.js" class Main{ constructor(){ new Glb() new Event() } } switch(document.readyState){ case "complete": case "interactive": new Main() break default: window.addEventListener("DOMContentLoaded" , (()=>new Main())) } glb.js import * as THREE from "three" import { GLTFLoader } from "GLTFLoader" import { DRACOLoader } from "DRACOLoader" import { Data } from "./data.js" import { Camera } from "./camera.js" import { Light } from "./light.js" import { Render } from "./render.js" export class Glb{ constructor(options){ options = options || {} switch(options.type){ // glb-loaded case "glb_data": this.load(options.url) break // init default : this.init() break } } init(){ Data.clock = new THREE.Clock(); Data.scene = new THREE.Scene() Data.texture = new THREE.Texture() Data.loader = new GLTFLoader() new Camera() new Light() new Render() } resize() { Data.camera.aspect = window.innerWidth / window.innerHeight Data.camera.updateProjectionMatrix() Data.renderer.setSize( window.innerWidth, window.innerHeight ) } } event.js import * as THREE from "three" import { GLTFLoader } from "GLTFLoader" import { DRACOLoader } from "DRACOLoader" import { OrbitControls } from "OrbitControls" import { Data } from "./data.js" import { Load } from "./load.js" import { Camera } from "./camera.js" export class Event{ constructor(){ document.addEventListener('mousedown' , this.mousedown.bind(this) , false) document.addEventListener('mousemove' , this.mousemove.bind(this) , false) document.addEventListener('mouseup' , this.mouseup.bind(this) , false) document.addEventListener('touchstart' , this.mousedown.bind(this) , false) document.addEventListener('touchmove' , this.mousemove.bind(this) , false) document.addEventListener('touchend' , this.mouseup.bind(this) , false) window.addEventListener('resize' , this.resize.bind(this) , false) Data.elm_upload_button.addEventListener("click" , (()=> {Data.elm_upload_file.click()})) Data.elm_upload_file.addEventListener("change" , ((e)=> new Load(e))) } resize() { Data.camera.aspect = window.innerWidth / window.innerHeight Data.camera.updateProjectionMatrix() Data.renderer.setSize( window.innerWidth, window.innerHeight ) } mousedown(e){ } mousemove(e){ } mouseup(){ } } data.js export class Data{ static clock = null static scene = null static texture = null static loader = null static camera = null static camera_control = null static renderer = null static light = null static ambient = null static mesh = null static mixer = null static move_data = null static move_weight = 100 static camera_center = {x:0, y:0, z:0} static camera_pos = {x:50, y:50, z:0} static object_scale = 1 static object_pos = {x:0, y:0, z:0} static root = { elm : document.getElementById("screen"), // bg_color : 0x00ffff, bg_color : 0x888888, } static axis = { x : 1, y : 1, z : 0, } static elm_upload_button = document.querySelector(`button[name="upload"]`) static elm_upload_file = document.querySelector(`input[type="file"][name="glb"]`) } load.js import * as THREE from "three" import { GLTFLoader } from "GLTFLoader" import { DRACOLoader } from "DRACOLoader" import { Glb } from "./glb.js" import { Data } from "./data.js" export class Load{ constructor(e){ this.read(e.target.files[0]) } read(file){ const read = new FileReader(); read.onload = this.readed.bind(this) read.readAsArrayBuffer(file) } readed(e){ const data = e.target.result const buf = new Uint8Array(data); const blob = new Blob([buf], {type: "model/gltf-binary"}) const url = URL.createObjectURL(blob) // new Glb({ // type : "glb_data", // url : url, // }) this.load(url) } load(url){ Data.loader.setCrossOrigin( 'anonymous' ) // r84 以降は明示的に setCrossOrigin() を指定する必要がある Data.loader.setDRACOLoader( new DRACOLoader() ) Data.loader.load( url, this.loaded.bind(this), function(xhr){console.log( ( xhr.loaded / xhr.total * 100 ) + '% loaded' )}, function(error){console.log( 'An error happened' )} ) } loaded(gltf){ Data.mesh = gltf.scene // transform Data.mesh.scale.set( Data.object_scale, Data.object_scale, Data.object_scale ) Data.mesh.rotation.set( 0,0,0 ) Data.mesh.position.set( Data.object_pos.x, Data.object_pos.y, Data.object_pos.z, ) const animations = gltf.animations if ( animations && animations.length ) { Data.mixer = new THREE.AnimationMixer(Data.mesh) for(const animation of animations){ Data.mixer.clipAction(animation).play() } } Data.scene.add(Data.mesh) } } camera.js import * as THREE from "three" import { GLTFLoader } from "GLTFLoader" import { DRACOLoader } from "DRACOLoader" import { OrbitControls } from "OrbitControls" import { Data } from "./data.js" export class Camera{ constructor(){ this.set_camera() } //カメラを作成 set_camera(){ Data.camera = new THREE.PerspectiveCamera( 20, window.innerWidth/window.innerHeight, 0.1, 1000 ) Data.camera_control = new OrbitControls(Data.camera, Data.root.elm); Camera.pos() } static pos(){ Data.camera.position.z = Data.camera_pos.z Data.camera.position.y = Data.camera_pos.y Data.camera.position.x = Data.camera_pos.x const center_pos = new THREE.Vector3(Data.camera_center.x, Data.camera_center.y, Data.camera_center.z) Data.camera.lookAt(center_pos); } } light.js import * as THREE from "three" import { GLTFLoader } from "GLTFLoader" import { DRACOLoader } from "DRACOLoader" import { Data } from "./data.js" export class Light{ constructor(){ this.set_light() } //光源を作成 set_light(){ Data.light = new THREE.DirectionalLight("#ffffff", 5) Data.ambient = new THREE.AmbientLight("#aaaaaa") Data.light.position.set( 0, 70, 100 ).normalize() Data.scene.add(Data.light) Data.scene.add(Data.ambient) } } render.js import * as THREE from "three" import { GLTFLoader } from "GLTFLoader" import { DRACOLoader } from "DRACOLoader" import { Data } from "./data.js" export class Render{ constructor(){ this.set_renderer() this.render() } //レンダラーを作成 set_renderer(){ Data.renderer = new THREE.WebGLRenderer() Data.renderer.setSize(window.innerWidth, window.innerHeight) Data.root.elm.appendChild(Data.renderer.domElement) //背景色を設定 Data.renderer.setClearColor(Data.root.bg_color, 1) } //毎フレーム時に実行されるループイベント render() { requestAnimationFrame( this.render.bind(this) ) if (Data.mixer) Data.mixer.update(Data.clock.getDelta()) Data.renderer.render(Data.scene, Data.camera) } }

ソース解説

わかりにくいプログラムは、できるだけ細切れのメソッドに分解するのが一番いいというプログラマーなら誰もが考えるやりかたで、機能分解してみました。 でも、データなどはそれぞれの関数内で共通で使わないと行けないので、Modelの役割のDataクラスを作り、グローバル(static)のみのデータをセットして使い回せるようにしています。 あと、他からアクセスする部品などは、グローバル関数で追加していけばいいと考えてます。 肝心のThreejsは、開発時はCDNを利用して使うようにします。 (本番の際は、ソース内に内包させます) 関連ライブラリなども必要なので、便利に使うために、index.htmlに、importmapで記載して使っていますが、古参のIE以外は新しいバージョンであれば、使えるので、とりあえずコレで。
参考: https://caniuse.com/?search=importmap ちなみに、GLTFLoaderは、GLBファイルを読み込んでThreejsで表示するモデルデータに変換してくれるライブラリで、OrbitControlsは、カメラワークなどをコントロールしてくれるライブラリとして使っています。

デモ

https://yugeta.github.io/game_libs/glb/ お手元にあるGLBファイルをアップロードすると画面に表示されます。 下に書かれているGithubのソースの中に、GLBデータも同梱されています。

注意点

オブジェクトサイズなどの自動判定は入れていないので、表示したオブジェクトが小さすぎたり大きすぎる場合があります。

開発中のGithub

https://github.com/yugeta/game_libs ソースコードも公開しちゃいます。 いや〜太っ腹ですね。 開発を進めていくとデータもどんどん改修されていきますが、今回の対応したプログラムはglbフォルダに格納してありますので、ソースが気になる人はそちらを御覧ください。 そして、今回作ったツールで、モデラーの人が独自に作ったモデルを実機表示確認できるという流れが作れました。 めでたしめでたし。 さて、次は何作ろう? ゲーム完成まではまだまだ程遠いですね・・・