【Three.js】地形データを使ってみる

JavaScript,Three.js

逆に富士山が特異な形なのね

はじめに

座標データさえあればなんでも表示できるんじゃない?って思ったんですよね。

なんかないかなぁって考えてたら「地形のデータはどこかにあるんじゃない?」って。ネットで探したらありました。

国土地理院のサイトからダウンロードできます。

ダウンロード方法

データが欲しい場所を画面に表示させた状態で【ツール】→【3D】→【大】と押していくとこんな画面が出てきます↓。

一番下の「WebGL用ファイル」のダウンロードボタンを押します。

「標高ファイル」のダウンロードボタンを押して好きな場所に保存します。

この地形データをこねくり回して色々作っていくわけですね。

csvファイルを読み込む

まずはダウンロードしたcsvファイルを読み込んでいきます。今回は日本の山TOP3ということで「富士山」「北岳」「奥穂高岳」の3つのcsvファイルを読み込みます。

let loadCount = 0;
let fujisanPoints;
let kitadakePoints;
let okuhotakadakePoints;

// 標高データ読み込み
loadCSV("./csv/fujisan.csv", 1);
loadCSV("./csv/kitadake.csv", 2);
loadCSV("./csv/okuhotakadake.csv", 3);

// 全ての標高データを読み込み終わるまで待つ
const intervalID = setInterval(function() {
  if (loadCount === 3) {
    clearInterval(intervalID);
    three();
  }
}, 10);

//*****************************************
// 標高データを読み込む
//*****************************************
function loadCSV(path, no) {
  const request = new XMLHttpRequest();
  request.open("GET", path, true);
  request.send(null);
  request.onload = function() {
    const csv = request.responseText.split(",");
    let num = 0;
    const buff = [];

    for (let i = 0; i < 257; i+=1) {
      for (let j = 0; j < 257; j+=3) {

        // 全てのデータ(66049個)を使うと処理が重い
        // 3分の1に間引く
        if (i % 3 == 0) {
          buff.push(j, csv[num] * 3, i);
          num += 3
        }

        if (i % 3 == 1 && j < 256) {
          buff.push(j + 1, csv[num] * 3, i);
          num += 3
        }

        if (i % 3 == 2 && j < 255) {
          buff.push(j + 2, csv[num] * 3, i);
          num += 3
        }

      }
    }

    // 変数に入れて読み込み完了カウンタを増やす
    if (no === 1) {
      fujisanPoints = buff;
      loadCount++;
    } else if (no === 2) {
      kitadakePoints = buff;
      loadCount++;
    } else if (no === 3) {
      okuhotakadakePoints = buff;
      loadCount++;
    }
  }
}

csvファイルには「高さ」のデータしかありません。3D空間で言うところのy座標です。なので、csvデータの前後にx座標とz座標を入れながら配列に格納していきます。37行目ですね。

31行目から52行目でデータを3分の1に減らしています。データが多すぎるせいでアニメーションさせる時にカクカクしてしまうからです。それでもまだ重たいけど、これ以上減らすとスカスカの地形になってしまうので仕方がないかなぁと思います。何やってるかを絵で描いてみました↓。

ここが一番大変でしたね。こういうピンポイントな問題ってググっても解決策は出てこないワケですよ。だから仕事中にもかかわらずコレの事を考えたりして。

仕事中に思いついた事は忘れないようにノートに書いておきました。それを家に持って帰って試すんです。で、動かないんです。だから面白いんだなぁと思います。

山を作る

const mountain = createMountain();

//***************************************
// 山を作成(最初は富士山の形にする)
//***************************************
function createMountain() {
  const points = new Float32Array(fujisanPoints);
  const geo = new THREE.BufferGeometry();
  geo.setAttribute("position",new THREE.BufferAttribute(points, 3));
  const mat = new THREE.PointsMaterial({size: 20, color: 0x666666});
  const mesh = new THREE.Points(geo,mat);
  mesh.position.set(-128, -60, -128);
  scene.add(mesh);
  return mesh
}

先ほど読み込んだデータを使って富士山型の山を作ります。山は1個だけ作っておいて、アニメーションを使って変形させていきます。

変形させる

//***************************************
// 北岳クリック
//***************************************
function clickKitadake() {

  titleFujisan.classList.add("unvisible");
  titleKitadake.classList.remove("unvisible");
  titleOkuhotakadake.classList.add("unvisible");

  listFujisan.addEventListener("click", clickFujisan);
  listKitadake.removeEventListener("click", clickKitadake);
  listOkuhotakadake.addEventListener("click", clickOkuhotakadake);

  transform(2);
}

//***********************************************************
// 山を変形させる
// 引数:選択された山(selectedMountain)
//***********************************************************
function transform(sm) {
  
  // 表示されている山の標高データを取得
  const beforeArray = mountain.geometry.attributes.position.array;

  // 選択された山の標高データを取得
  let afterArray;
  if (sm == 1) {
    afterArray = fujisanPoints
  } else if (sm == 2) {
    afterArray = kitadakePoints;
  } else if (sm == 3) {
    afterArray = okuhotakadakePoints;
  }

  for (let i = 0; i < beforeArray.length; i += 3) {

    const b = {x: beforeArray[i], y: beforeArray[i + 1], z: beforeArray[i + 2]};
    const a = {x: afterArray[i], y: afterArray[i + 1], z: afterArray[i + 2]};

    new TWEEN.Tween(b)
        .to(a, 2000)
        .onUpdate(function() {
          beforeArray[i] = b.x;
          beforeArray[i + 1] = b.y;
          beforeArray[i + 2] = b.z;
          mountain.geometry.attributes.position.needsUpdate = true;
        })
        .start();
  }
}

山の名前リストがクリックされたら、HTML要素にクラスを追加したり削除したり。名前リストのイベントリスナーを追加したり削除したり。そして最後に変形用の関数を呼び出します。

変形用の関数の中では、変形前と変形後のデータを取得してTWEENを使ってアニメーションさせています。

イイ感じにする

「和」な感じになるように文字を装飾します。Android端末には明朝体のフォントが無いみたいなのでWebフォントも使いました。アレコレやってできたのがコレです↓。

デモサイトはこちら

水墨画みたいな雰囲気でなかなか良いんじゃないでしょうか。全コードを載せておきます。

<!DOCTYPE html>

<html lang="ja">

<head>
  <meta charset="UTF-8">
  <title>日本の山</title>
  <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
  
  <link rel="preconnect" href="https://fonts.gstatic.com">
  <link href="https://fonts.googleapis.com/css2?family=Noto+Serif+JP:wght@500&display=swap&text=富士山北岳奥穂高岳" rel="stylesheet">
  
  <script type="text/javascript" src="./libs/utils/Tween.js"></script>
  
  <style>
      * {
        margin: 0;
        padding: 0;
      }
      html,body {
          overflow: hidden;
          height: 100%;
          font-family: "HG正楷書体-PRO", "Noto Serif JP", "Hiragino Mincho Pro", serif;
      }
      .name {
        position: absolute;
        top: 0;
        left: -7%;
        width: 50%;
        height: 100%;
        display: flex;
        justify-content: center;
        align-items: center;
        writing-mode: vertical-rl;
        transition: 1s;
      }
      .name p {
        font-size: max(7vw, 48px);
        text-shadow: 0 0 5px #333333;
        color: #333333;
      }
      .name p span {
        border: 1px solid #333333;
        border-bottom: 0;
        padding: 1vw;
        background-color: rgba(50, 50, 50, 0.1);
      }
      .name p span.red {
        background-color: rgba(250, 50, 50, 0.5);
      }
      .name p span.last {
        border-bottom: 1px solid #333333;
      }
      .unvisible {
        opacity: 0;
      }
      .menu {
        position: absolute;
        top: 0;
        right: 0;
        writing-mode: vertical-rl;
        font-size: max(2vw, 18px);
        margin-top: max(6vw, 64px);
        margin-right: max(10vw, 48px);
        list-style-type: none;
      }
      .menu li {
        margin-left: max(3vw, 24px);
        cursor: pointer;
      }
      .menu li::before {
        position: relative;
        top: -10px;
        display: inline-block;
        width: 5px;
        height: 5px;
        content: '';
        border-radius: 100%;
        background: #333333;
      }
  </style>
</head>

<body>
  
  <div id="screen"></div>
  
  <div id="fujisan" class="name">
    <p><span class="red">富</span><span>士</span><span class="last">山</span></p>
  </div>
  
  <div id="kitadake" class="name unvisible">
    <p><span>北</span><span class="red last">岳</span></p>
  </div>
  
  <div id="okuhotakadake" class="name unvisible">
    <p><span>奥</span><span class="red">穂</span><span>高</span><span class="last">岳</span></p>
  </div>

  <ul class="menu">
    <li id="liFujisan">富士山</li>
    <li id=liKitadake>北岳</li>
    <li id=liOkuhotakadake>奥穂高岳</li>
  </ul>
  
  <script type="module">
    
    import * as THREE from "./libs/0.128.0/build/three.module.js";
    import { OrbitControls } from "./libs/0.128.0/examples/jsm/controls/OrbitControls.js";

    window.onload = function() {

      let loadCount = 0;
      let fujisanPoints;
      let kitadakePoints;
      let okuhotakadakePoints;

      // 標高データ読み込み
      loadCSV("./csv/fujisan.csv", 1);
      loadCSV("./csv/kitadake.csv", 2);
      loadCSV("./csv/okuhotakadake.csv", 3);

      // 全ての標高データを読み込み終わるまで待つ
      const intervalID = setInterval(function() {
        if (loadCount === 3) {
          clearInterval(intervalID);
          three();
        }
      }, 10);

      //*****************************************
      // 標高データを読み込む
      //*****************************************
      function loadCSV(path, no) {
        const request = new XMLHttpRequest();
        request.open("GET", path, true);
        request.send(null);
        request.onload = function() {
          const csv = request.responseText.split(",");
          let num = 0;
          const buff = [];

          for (let i = 0; i < 257; i+=1) {
            for (let j = 0; j < 257; j+=3) {

              // 全てのデータ(66049個)のデータを使うと処理が重い
              // 3分の1に間引く
              if (i % 3 == 0) {
                buff.push(j, csv[num] * 3, i);
                num += 3
              }

              if (i % 3 == 1 && j < 256) {
                buff.push(j + 1, csv[num] * 3, i);
                num += 3
              }

              if (i % 3 == 2 && j < 255) {
                buff.push(j + 2, csv[num] * 3, i);
                num += 3
              }

            }
          }

          // 変数に入れて読み込み完了カウンタを増やす
          if (no === 1) {
            fujisanPoints = buff;
            loadCount++;
          } else if (no === 2) {
            kitadakePoints = buff;
            loadCount++;
          } else if (no === 3) {
            okuhotakadakePoints = buff;
            loadCount++;
          }
        }
      }

      //*****************************************
      // three.jsいろいろ
      //*****************************************
      function three() {

        let scene, camera, webGLRenderer;
        let orbitControls;
        let aspRatio, fov;
        
        init();

        // 山を作成
        const mountain = createMountain();
        
        // タイトル要素取得
        const titleFujisan = document.getElementById("fujisan");
        const titleKitadake = document.getElementById("kitadake");
        const titleOkuhotakadake = document.getElementById("okuhotakadake");
        
        // リスト要素取得
        const listFujisan = document.getElementById("liFujisan");
        const listKitadake = document.getElementById("liKitadake");
        const listOkuhotakadake = document.getElementById("liOkuhotakadake");
        
        renderScene();
        window.addEventListener("resize", resizeWindow);
        // リストにクリックイベントリスナーを設定
        // 富士山は初めに表示されているのでここでは設定しない
        document.getElementById("liKitadake").addEventListener("click", clickKitadake);
        document.getElementById("liOkuhotakadake").addEventListener("click", clickOkuhotakadake);
        
        //***************************************
        // 富士山クリック
        //***************************************
        function clickFujisan() {
          
          titleFujisan.classList.remove("unvisible");
          titleKitadake.classList.add("unvisible");
          titleOkuhotakadake.classList.add("unvisible");

          listFujisan.removeEventListener("click", clickFujisan);
          listKitadake.addEventListener("click", clickKitadake);
          listOkuhotakadake.addEventListener("click", clickOkuhotakadake);

          transform(1);
        }

        //***************************************
        // 北岳クリック
        //***************************************
        function clickKitadake() {

          titleFujisan.classList.add("unvisible");
          titleKitadake.classList.remove("unvisible");
          titleOkuhotakadake.classList.add("unvisible");

          listFujisan.addEventListener("click", clickFujisan);
          listKitadake.removeEventListener("click", clickKitadake);
          listOkuhotakadake.addEventListener("click", clickOkuhotakadake);

          transform(2);
        }

        //***************************************
        // 奥穂高岳クリック
        //***************************************
        function clickOkuhotakadake() {

          titleFujisan.classList.add("unvisible");
          titleKitadake.classList.add("unvisible");
          titleOkuhotakadake.classList.remove("unvisible");

          listFujisan.addEventListener("click", clickFujisan);
          listKitadake.addEventListener("click", clickKitadake);
          listOkuhotakadake.removeEventListener("click", clickOkuhotakadake);

          transform(3);
        }

        //***************************************
        // 山を作成(最初は富士山の形にする)
        //***************************************
        function createMountain() {
          const points = new Float32Array(fujisanPoints);
          const geo = new THREE.BufferGeometry();
          geo.setAttribute("position",new THREE.BufferAttribute(points, 3));
          const mat = new THREE.PointsMaterial({size: 20, color: 0x666666});
          const mesh = new THREE.Points(geo,mat);
          mesh.position.set(-128, -60, -128);
          scene.add(mesh);
          return mesh
        }
        
        //***********************************************************
        // 山を変形させる
        // 引数:選択された山(selectedMountain)
        //***********************************************************
        function transform(sm) {
          
          // 表示されている山の標高データを取得
          const beforeArray = mountain.geometry.attributes.position.array;

          // 選択された山の標高データを取得
          let afterArray;
          if (sm == 1) {
            afterArray = fujisanPoints
          } else if (sm == 2) {
            afterArray = kitadakePoints;
          } else if (sm == 3) {
            afterArray = okuhotakadakePoints;
          }

          for (let i = 0; i < beforeArray.length; i += 3) {

            const b = {x: beforeArray[i], y: beforeArray[i + 1], z: beforeArray[i + 2]};
            const a = {x: afterArray[i], y: afterArray[i + 1], z: afterArray[i + 2]};

            new TWEEN.Tween(b)
                .to(a, 2000)
                // .easing(TWEEN.Easing.Exponential.InOut)
                .onUpdate(function() {
                  beforeArray[i] = b.x;
                  beforeArray[i + 1] = b.y;
                  beforeArray[i + 2] = b.z;
                  mountain.geometry.attributes.position.needsUpdate = true;
                })
                .start();
          }
        }

        //*****************************************
        // 初期化
        //*****************************************
        function init() {
          scene = new THREE.Scene();
          scene.fog = new THREE.Fog(0xfafab6, 150, 320);
      
          aspRatio = window.innerWidth / window.innerHeight;
          fov = getFov(aspRatio);
          camera = new THREE.PerspectiveCamera(fov, aspRatio, 1, 10000);
          camera.position.set(0, 40, 200);
          scene.add(camera);
    
          webGLRenderer = new THREE.WebGLRenderer();
          webGLRenderer.setSize(window.innerWidth, window.innerHeight);
          webGLRenderer.setClearColor(new THREE.Color(0xfafab6));
          document.getElementById("screen").appendChild(webGLRenderer.domElement);
  
          orbitControls = new OrbitControls(camera, webGLRenderer.domElement);
          orbitControls.autoRotate = true;
          orbitControls.enabled = false; 
          orbitControls.target = new THREE.Vector3(0, 10, 0);
        }
  
        //*****************************************
        // 度→ラジアン
        //*****************************************
        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();
        }
        
        //*****************************************
        // 視野角取得
        //*****************************************
        function getFov(aspRatio) {
          let fov;
          if (aspRatio > 1) {
            fov = 25;
          } else if (aspRatio > 0.8) {
            fov = 30;
          } else {
            fov = 40;
          }
          return fov;
        }
        
        //*****************************************
        // 描画
        //*****************************************
        function renderScene() {
          orbitControls.update();
          TWEEN.update();
          requestAnimationFrame(renderScene);
          webGLRenderer.render(scene, camera);
        }
      }
    }
  </script>
</body>
</html>

あとがき

世の中には色々なデータがありますね。建物のデータもあるみたいだし。

ただデータ量が膨大で重たくなりますね。今回作ったヤツもスマホだとちょっとキビシイかなぁと思います。

今回は以上です。ありがとうございました。

おしゃれ度

★★★☆☆

Posted by ナカタ