
以前から計画していた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フォルダに格納してありますので、ソースが気になる人はそちらを御覧ください。
そして、今回作ったツールで、モデラーの人が独自に作ったモデルを実機表示確認できるという流れが作れました。
めでたしめでたし。
さて、次は何作ろう?
ゲーム完成まではまだまだ程遠いですね・・・
 
0 件のコメント:
コメントを投稿