【Three.js】カメラの移動について学びました

JavaScript,Three.js

あまり理解してない

はじめに

カメラを制する者は、3Dで作ったなんか色々なものを制す

語呂が悪い

さっき思いついたヤツなんですけどね。でもそう思います。何かが動いているだけでも面白いのに、その上カメラまで動いちゃったらどうすんのって。

作ったもの

デモサイトはこちら

画面のスクロールに合わせてカメラが移動することで、いろいろな角度から車を眺めることができます。

車とカメラを入れる箱を用意する

//*************************************************************
// 回転体を作成する
// この中に車とカメラとカメラポジションを入れて一緒に回す
//*************************************************************
let rotateObj;
function createRotateObj() {
  rotateObj = new THREE.Object3D();
  rotateObj.rotation.x = degToRad(80);
}

車は地球の上を走っています。そしてカメラは常に車を狙っているので、車の動きに合わせて動かないとダメ。車とカメラを別々に動かすとややこしくなりそうだなぁと思ったのでrotateObjという名前の入れ物を作りました。

その入れ物の中に車とカメラを入れて、入れ物ごと回転させてしまうのです。そうすると車とカメラの相対的な位置は変わらないので座標の管理が楽チンになるというワケです。

車は地球の経線に対してちょっとだけ斜めに走らせたいので、rotateObj.rotation.x = degToRad(80)として、入れ物をあらかじめ傾けておきます。

カメラの移動位置をあらかじめ作っておく

//***************************************************
// カメラポジションを作成する
// 配列cameraPositionsに入れておく
//***************************************************
let cameraPosition, pov;
function createCameraPositions() {
  
  // カメラ1のポジション
  const cp1 = new THREE.Object3D();
  const pos1 = new THREE.Vector3();
  pos1.setFromSphericalCoords(30, degToRad(90), degToRad(60));
  cp1.position.set(pos1.x, pos1.y, pos1.z);
  rotateObj.add(cp1);
  cameraPositions.push(cp1);

  // カメラ2のポジション
  const cp2 = new THREE.Object3D();
  const pos2 = new THREE.Vector3();
  pos2.setFromSphericalCoords(17, degToRad(140), degToRad(85));
  cp2.position.set(pos2.x, pos2.y, pos2.z);
  rotateObj.add(cp2);
  cameraPositions.push(cp2);

  // カメラ3のポジション
  const cp3 = new THREE.Object3D();
  const pos3 = new THREE.Vector3();
  pos3.setFromSphericalCoords(11, degToRad(85), degToRad(80));
  cp3.position.set(pos3.x, pos3.y, pos3.z);
  rotateObj.add(cp3);
  cameraPositions.push(cp3);

  // カメラ4のポジション
  const cp4 = new THREE.Object3D();
  const pos4 = new THREE.Vector3();
  pos4.setFromSphericalCoords(12, degToRad(100), degToRad(110));
  cp4.position.set(pos4.x, pos4.y, pos4.z);
  rotateObj.add(cp4);
  cameraPositions.push(cp4);

  // カメラ5のポジション
  const cp5 = new THREE.Object3D();
  const pos5 = new THREE.Vector3();
  pos5.setFromSphericalCoords(20, degToRad(70), degToRad(120));
  cp5.position.set(pos5.x, pos5.y, pos5.z);
  rotateObj.add(cp5);
  cameraPositions.push(cp5);

  // ついでにカメラが見るポジションも作っておく
  pov = new THREE.Object3D();
  const pos6 = new THREE.Vector3();
  pos6.setFromSphericalCoords(10.08, degToRad(90), degToRad(96));
  pov.position.set(pos6.x, pos6.y, pos6.z);
  rotateObj.add(pov);
}

カメラの移動先となる座標を作っておいて、それもrotateObjに入れておきます。管理しやすいようにcameraPositionsという名前の配列にも入れてしまいましょう。

ついでにカメラの視線も作っています。カメラは常に「車の少し前」を見続けることになります。

Tweenを使ってアニメーション用関数を作成

//***************************************************
// カメラのポジションを変更する
//****************************************************
const dir = new THREE.Vector3();
function changeCameraPosition(targetPosition) {
  TWEEN.removeAll();
  new TWEEN.Tween(camera.position)
    .to(targetPosition, 1000)
    .easing(TWEEN.Easing.Quadratic.InOut)
    .onUpdate(function() {
      dir.copy(camera.getWorldPosition(new THREE.Vector3())).multiplyScalar(2);
      camera.up.copy(dir)
      camera.lookAt(pov.getWorldPosition(new THREE.Vector3()));
    })
    .start();
}

12行目のcamera.up.copy(dir)でカメラの頭の向きを設定しています。これは、その後のcamera.lookAtメソッドで視線の設定もしてあげないと効かないようです。

アニメーション用関数を呼び出す

//***************************************************
// ウィンドウスクロール
//***************************************************
window.addEventListener("scroll", scrollWindow);
let cpNumber = 0;
function scrollWindow() {
  const windowH = window.innerHeight;
  const thirdTop = document.getElementById("third").getBoundingClientRect().top;
  const secondTop = document.getElementById("second").getBoundingClientRect().top;
  const firstTop = document.getElementById("first").getBoundingClientRect().top;
  const bangaiTop = document.getElementById("bangai").getBoundingClientRect().top;

  if (thirdTop >= windowH && cpNumber !== 0) {
    cpNumber = 0;
    changeCameraPosition(cameraPositions[0].position);
  }

  if (thirdTop < windowH && secondTop >= windowH && cpNumber !== 1) {
    cpNumber = 1;
    changeCameraPosition(cameraPositions[1].position);
  }

  if (secondTop < windowH && firstTop >= windowH && cpNumber !== 2) {
    cpNumber = 2;
    changeCameraPosition(cameraPositions[2].position);
  }

  if (firstTop < windowH && bangaiTop >= windowH && cpNumber !== 3) {
    cpNumber = 3;
    changeCameraPosition(cameraPositions[3].position);
  }

  if (bangaiTop < windowH && cpNumber !== 4) {
    cpNumber = 4;
    changeCameraPosition(cameraPositions[4].position);
  }

}

ある地点までスクロールしたら先程作ったアニメーション用関数を呼び出して完成。

全コードを載せておきます

HTML

<div id="screen"></div>
<div id="htmlScreen">

  <h1>好きなゲーム音楽<br>BEST3</h1>
  <div class="spacer"></div>

  <div id="third" class="wrap">
    <h2 class="title">【第3位】 Beginning</h2>
    <p class="gameTitle">― 悪魔城伝説 ―</p>
    <div class="container">
      <img class="pict" src="./picture/bgm3/akuma.jpg" alt="悪魔上伝説のパッケージ絵">
      <p class="comment">最初のステージで流れるBGM。あまりにもカッコイイからコントローラーを放置してBGMをずっと聴いているプレイヤーが続出したとかしないとか。</p>
    </div>
  </div>

  <div class="spacer"></div>

  <div id="second" class="wrap">
    <h2 class="title">【第2位】 シーモアバトル</h2>
    <p class="gameTitle">― FINAL FANTASY X ―</p>
    <div class="container">
      <img class="pict" src="./picture/bgm3/simoa.jpg" alt="シーモアの画像">
      <p class="comment">何をしたかったのかよく分からない人、シーモアとの最終戦で流れるBGM。ピコピコ音の少し不思議な曲。リマスター版よりオリジナル版が好き。</p>
    </div>
  </div>

  <div class="spacer"></div>

  <div id="first" class="wrap">
    <h2 class="title">【第1位】 Zero</h2>
    <p class="gameTitle">― Ace Combat Zero ―</p>
    <div class="container">
      <img class="pict" src="./picture/bgm3/zero.jpg" alt="戦闘機の画像">
      <p class="comment">「最終局面での元相棒との一騎打ち」という激アツな場面で流れるBGM。「今そこで彼を討てるのは君だけだ」と言われた時の圧倒的主人公感。完璧です。</p>
    </div>
  </div>

  <div class="spacer"></div>

  <div id="bangai" class="wrap">
    <h2 class="title">【番外】 決戦!サルーイン</h2>
    <p class="gameTitle">― ロマンシングサガ ミンストレルソング ―</p>
    <div class="container">
      <img class="pict" src="./picture/bgm3/isikawa.jpg" alt="石川の画像">
      <p class="comment">すごくカッコイイ曲なのに、ニコニコ動画に投稿された「ある動画」のせいで、聴こえるはずのない歌詞や見えるはずのない映像が脳内で再生されてしまうという、ある意味被害を受けた曲。</p>
    </div>
  </div>

  <div class="spacer"></div>

</div>

CSS

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
  font-weight: normal;
  color: #333333;
}
html,body {
    overflow-x: hidden;
}
#screen {
  position: fixed;
}
#htmlScreen {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  padding: max(10vw, 32px);
  text-align: center;
}
h1 {
  font-size: clamp(24px, 8vw, 74px);
  color: white;
  text-shadow: 0px 0px 5px white;
}
.wrap {
  padding: 4vw;
  text-align: left;
  background-color: rgba(255, 255, 255, 0.5);
  box-shadow: 0px 0px 10px white;
}
.title {
  margin-left: max(2vw, 10px);
  font-size: clamp(16px, 5vw, 64px);
}
.gameTitle {
  font-size: clamp(12px, 2vw, 32px);
  text-align: right;
  margin-right: max(4.5vw, 26px);
}
.container {
  position: relative;
  display: flex;
  align-items: center;
  width: 100%;
}
.pict {
  width: max(30vw, 250px);
  margin: max(2vw, 10px);
}
.comment {
  margin: max(2vw, 10px);
  font-size: clamp(16px, 2.5vw, 32px);
}
.spacer {
  height: 150vh;
}
@media (max-width: 799px) {
  h1 {
    margin-top: 10vh;
  }
  .container {
    flex-direction: column;
  }
}
@media (max-width: 499px) {
  h1 {
    margin-top: 20vh;
  }
}

JavaScript

<script type="module">
  
  import * as THREE from "./libs/0.128.0/build/three.module.js";
  import { GLTFLoader } from "./libs/0.128.0/examples/jsm/loaders/GLTFLoader.js";
  import { TWEEN } from "https://unpkg.com/three@0.127.0/examples/jsm/libs/tween.module.min.js";
  
  window.onload = function() {
    
    let scene, camera, webGLRenderer;
    let aspRatio, fov;
    const cameraPositions = [];      // カメラポジション用の配列
    let cameraPosition, pov;
    let earth;
    let rotateObj;
    let car;
    let carDeg = 0;
    const cloudInfos = [];           // 雲情報用の配列
    const CLOUDS_NUM = 24;           // 雲の数

    createRotateObj();
    createCameraPositions(); 
    init();
    createEarth();
    createCloud();
    createCar();
    
    //***************************************************
    // 車と雲のモデルを読み込み終わるまで描画しない
    //***************************************************
    const intervalID = setInterval(function() {
      if (carFlag === true && cloudFlag === true) {
        clearInterval(intervalID);
        renderScene();
      }
    }, 10);

    //***************************************************
    // 初期化
    //***************************************************
    function init() {

      scene = new THREE.Scene();  
      scene.add(rotateObj);

      aspRatio = window.innerWidth / window.innerHeight;
      fov = getFov(aspRatio);
      camera = new THREE.PerspectiveCamera(fov, aspRatio, 1, 2000);

      // カメラの座標(ローカル)
      camera.position.copy(cameraPositions[0].position);

      // カメラの頭方向
      const dir = new THREE.Vector3();
      dir.copy(camera.position).multiplyScalar(2);
      camera.up.set(dir.x, dir.y, dir.z);

      //カメラが見ている座標(ローカル)
      camera.lookAt(pov.position);

      rotateObj.add(camera);    

      webGLRenderer = new THREE.WebGLRenderer({antialias: true});
      webGLRenderer.setSize(window.innerWidth, window.innerHeight);
      webGLRenderer.setClearColor(new THREE.Color(0x02c1d6));
      webGLRenderer.shadowMap.enabled = true;
      webGLRenderer.outputEncoding = THREE.sRGBEncoding;
      document.getElementById("screen").appendChild(webGLRenderer.domElement);

      const dirLight = new THREE.DirectionalLight(0xffffff);
      dirLight.position.set(20, 40, 20);
      dirLight.castShadow = true;
      dirLight.shadow.camera.near = 1;
      dirLight.shadow.camera.far = 75;
      dirLight.shadow.camera.left = -30;
      dirLight.shadow.camera.right = 30;
      dirLight.shadow.camera.top = 30;
      dirLight.shadow.camera.bottom = -30;
      dirLight.shadow.mapSize.width = 1024;
      dirLight.shadow.mapSize.height = 1024;
      scene.add(dirLight);  

      const ambiLight = new THREE.AmbientLight(0x404040);
      scene.add(ambiLight);

      window.addEventListener("resize", resizeWindow);
      window.addEventListener("scroll", scrollWindow);
    }

    //*************************************************************
    // 回転体を作成する
    // この中に車とカメラとカメラポジションを入れて一緒に回す
    //*************************************************************
    function createRotateObj() {
      rotateObj = new THREE.Object3D();
      rotateObj.rotation.x = degToRad(80);
    }

    //***************************************************
    // 地球を作成する
    //***************************************************
    function createEarth() {
      const r = 10;
      const textureLoader = new THREE.TextureLoader();
      const earthTexture = textureLoader.load("./texture/planet/Earth.png");
      const geo = new THREE.SphereGeometry(r, 50, 50);
      const mat = new THREE.MeshLambertMaterial({map: earthTexture});
      earth = new THREE.Mesh(geo, mat);
      earth.receiveShadow = true;
      scene.add(earth);
    }
    
    //***************************************************
    // 雲情報クラス
    //***************************************************
    class cloudInfo {

      /*---------------
        コンストラクタ
      ---------------*/
      constructor(phi, theta, speed, scale, deg) {
        this.cloud = null;     // 雲そのもの
        this.phi = phi;        // 緯度
        this.theta = theta;    // 経度
        this.speed = speed;    // 速度
        this.scale = scale;    // 大きさ
        this.deg = deg;        // 大きさを変化させるための値
      }

      /*---------------
        動く用メソッド
      ---------------*/
      update() {

        // 位置
        this.theta += this.speed;
        const pos = new THREE.Vector3();
        pos.setFromSphericalCoords(12, degToRad(this.phi), degToRad(this.theta));
        this.cloud.position.set(pos.x, pos.y, pos.z);

        // 向き
        const dir = new THREE.Vector3();
        dir.copy(this.cloud.position).multiplyScalar(2);
        this.cloud.lookAt(dir);

        // 大きさ
        this.deg += 0.05;
        this.cloud.scale.x = this.scale + (Math.sin(this.deg) * 0.05);
        this.cloud.scale.y = this.scale + (Math.sin(this.deg) * 0.05);
        this.cloud.scale.z = this.scale + (Math.sin(this.deg) * 0.05);
      }
    }

    //***************************************************
    // 雲を作成する
    //***************************************************
    let cloudFlag = false;
    function createCloud() {

      const loader = new GLTFLoader();
      const cloudURL = "./gltf/cloud.glb";
      let loadCnt = 0;

      for (let i = 0; i < CLOUDS_NUM; i++) {
        
        loader.load(cloudURL, function(gltf) {
          
          // 雲情報の設定
          const p = Math.floor(Math.random() * (150 - 30) + 30);
          const t = Math.floor(Math.random() * 359);
          let sp;
          let sc;
          if (i % 3 == 0) {
            sp = 0.04;
            sc = 0.5;
          } else if (i % 3 == 1) {
            sp = 0.06;
            sc = 0.4;
          } else if (i % 3 == 2) {
            sp = 0.08
            sc = 0.3;
          }
          const d = (Math.floor(Math.random() * (10 - 5) + 5)) * 10;

          // 雲情報オブジェクト作成
          const ci = new cloudInfo(p, t, sp, sc, d);
          ci.cloud = gltf.scene;

          // 影の設定
          for (let i = 0; i < ci.cloud.children.length; i++) {
            ci.cloud.children[i].castShadow = true;
          }

          scene.add(ci.cloud);
          cloudInfos.push(ci);

          // すべての雲の読み込むが完了したらフラグを立てる
          loadCnt += 1;
          if (loadCnt === 24) {
            cloudFlag = true;
          }
        });
      }
    }
    
    //***************************************************
    // 車を作成する
    //***************************************************
    let carFlag = false;
    function createCar() {
      
      const loader = new GLTFLoader();
      const carURL = "./gltf/car.glb";
      
      loader.load(carURL, function(gltf) {
        
        car = gltf.scene;
        
        // 影の設定
        for (let i = 0; i < car.children.length; i++) {
          car.children[i].castShadow = true;
        }
        
        // 車の座標
        car.position.setFromSphericalCoords(10.08, degToRad(90), degToRad(90));
        
        // 車の頭方向 
        const dir = new THREE.Vector3();
        dir.copy(car.position).multiplyScalar(2);
        car.up.set(dir.x, dir.y, dir.z);

        // 車が見ている座標
        car.lookAt(pov.position);

        // 大きさ
        car.scale.set(0.1, 0.1, 0.1);
        
        // 回転体に入れる
        rotateObj.add(car);

        // 読み込みが完了したらフラグを立てる
        carFlag = true;
      });
    }

    //***************************************************
    // カメラポジションを作成する
    // 配列cameraPositionsに入れておく
    //***************************************************
    function createCameraPositions() {
      
      // カメラ1のポジション
      const cp1 = new THREE.Object3D();
      const pos1 = new THREE.Vector3();
      pos1.setFromSphericalCoords(30, degToRad(90), degToRad(60));
      cp1.position.set(pos1.x, pos1.y, pos1.z);
      rotateObj.add(cp1);
      cameraPositions.push(cp1);

      // カメラ2のポジション
      const cp2 = new THREE.Object3D();
      const pos2 = new THREE.Vector3();
      pos2.setFromSphericalCoords(17, degToRad(140), degToRad(85));
      cp2.position.set(pos2.x, pos2.y, pos2.z);
      rotateObj.add(cp2);
      cameraPositions.push(cp2);

      // カメラ3のポジション
      const cp3 = new THREE.Object3D();
      const pos3 = new THREE.Vector3();
      pos3.setFromSphericalCoords(11, degToRad(85), degToRad(80));
      cp3.position.set(pos3.x, pos3.y, pos3.z);
      rotateObj.add(cp3);
      cameraPositions.push(cp3);

      // カメラ4のポジション
      const cp4 = new THREE.Object3D();
      const pos4 = new THREE.Vector3();
      pos4.setFromSphericalCoords(12, degToRad(100), degToRad(110));
      cp4.position.set(pos4.x, pos4.y, pos4.z);
      rotateObj.add(cp4);
      cameraPositions.push(cp4);

      // カメラ5のポジション
      const cp5 = new THREE.Object3D();
      const pos5 = new THREE.Vector3();
      pos5.setFromSphericalCoords(20, degToRad(70), degToRad(120));
      cp5.position.set(pos5.x, pos5.y, pos5.z);
      rotateObj.add(cp5);
      cameraPositions.push(cp5);

      // ついでにカメラが見るポジションも作っておく
      pov = new THREE.Object3D();
      const pos6 = new THREE.Vector3();
      pos6.setFromSphericalCoords(10.08, degToRad(90), degToRad(96));
      pov.position.set(pos6.x, pos6.y, pos6.z);
      rotateObj.add(pov);
    }

    //***************************************************
    // 度→ラジアン
    //***************************************************
    function degToRad(deg) {
      return deg * Math.PI / 180;
    }

    //***************************************************
    // ラジアン→度
    //***************************************************
    function radToDeg(rad) {
      return rad * 180 / Math.PI;
    }

    //***************************************************
    // ウィンドウリサイズ
    //***************************************************
    function resizeWindow() {
      aspRatio = window.innerWidth / window.innerHeight;
      camera.aspect = aspRatio
      camera.fov = getFov(aspRatio);
      camera.updateProjectionMatrix();
      webGLRenderer.setSize(window.innerWidth, window.innerHeight);
      renderScene();
    }
    
    //***************************************************
    // ウィンドウスクロール
    //***************************************************
    let cpNumber = 0;
    function scrollWindow() {
      const windowH = window.innerHeight;
      const thirdTop = document.getElementById("third").getBoundingClientRect().top;
      const secondTop = document.getElementById("second").getBoundingClientRect().top;
      const firstTop = document.getElementById("first").getBoundingClientRect().top;
      const bangaiTop = document.getElementById("bangai").getBoundingClientRect().top;

      if (thirdTop >= windowH && cpNumber !== 0) {
        cpNumber = 0;
        changeCameraPosition(cameraPositions[0].position);
      }

      if (thirdTop < windowH && secondTop >= windowH && cpNumber !== 1) {
        cpNumber = 1;
        changeCameraPosition(cameraPositions[1].position);
      }

      if (secondTop < windowH && firstTop >= windowH && cpNumber !== 2) {
        cpNumber = 2;
        changeCameraPosition(cameraPositions[2].position);
      }

      if (firstTop < windowH && bangaiTop >= windowH && cpNumber !== 3) {
        cpNumber = 3;
        changeCameraPosition(cameraPositions[3].position);
      }

      if (bangaiTop < windowH && cpNumber !== 4) {
        cpNumber = 4;
        changeCameraPosition(cameraPositions[4].position);
      }
      
    }

    //***************************************************
    // カメラのポジションを変更する
    //****************************************************
    const dir = new THREE.Vector3();
    function changeCameraPosition(targetPosition) {
      TWEEN.removeAll();
      new TWEEN.Tween(camera.position)
        .to(targetPosition, 1000)
        .easing(TWEEN.Easing.Quadratic.InOut)
        .onUpdate(function() {
          dir.copy(camera.getWorldPosition(new THREE.Vector3())).multiplyScalar(2);
          camera.up.copy(dir)
          camera.lookAt(pov.getWorldPosition(new THREE.Vector3()));
        })
        .start();
    }

    //***************************************************
    // 視野角取得
    //***************************************************
    function getFov(aspRatio) {
      let fov;
      if (aspRatio > 1) {
        fov = 25;
      } else if (aspRatio > 0.8) {
        fov = 30;
      } else if (aspRatio > 0.6) {
        fov = 40;
      } else if (aspRatio > 0.5) {
        fov = 50;
      } else {
        fov = 60;
      }
      return fov;
    }
    
    //***************************************************
    // 描画
    //***************************************************
    function renderScene() {
      requestAnimationFrame(renderScene);
      webGLRenderer.render(scene, camera);

      /*-------------
          回転体を回す
      -------------*/
      rotateObj.rotation.y += 0.002;
      
      /*---------------
        雲を変化させる
      ---------------*/
      for (let i = 0; i < CLOUDS_NUM; i++) {
        cloudInfos[i].update();
      }
      
      /*------------------
        車をガタガタ揺らす
      ------------------*/
      carDeg += 20;
      const pos = new THREE.Vector3();
      pos.setFromSphericalCoords(10.08 + (Math.sin(degToRad(carDeg))) * 0.003, degToRad(90), degToRad(90));
      car.position.set(pos.x, pos.y, pos.z);

      TWEEN.update();
    }
  }
</script>

あとがき

もっとキレイなコードにしたいなぁ。とか思いながらも「動いたし、まぁいいか」って感じになってしまいました…。

でもCSSは以前よりキレイに書けるようになったかなぁ。maxとかclampとか使うようにしたら、レスポンシブ対応がやりやすくなりました。これは良いもの見つけました。

3Dモデリングにも初挑戦しました。自分で作った車とか雲を動かすのはすごく楽しいですね。でもblenderの使い方がまったく分かっていないので、そこも勉強したいと思います。

あとアレですよねぇ。イメージ通りのものはできたけど、結局「背景が3Dになってる」ってだけなんですよねぇ。なんかもっと「えっ!?こんなトコが!?」みたいなのが作れたらいいなぁと思います。今回は以上です。ありがとうございました。

おしゃれ度

★★★★☆

Posted by ナカタ