ITエンジニア勉強ブログ

自分が学んだことを共有していきます。

Three.jsでミニチュアの箱庭を制作 ver.0.1

Three.jsでミニチュアの箱庭?っぽいものを作ってみました。


See the Pen
Untitled
by Imai (@imai1)
on CodePen.


コード解説

準備

以下のライブラリを読み込みます。

<script src="https://unpkg.com/three@0.142.0/build/three.min.js"></script>
<script src="https://unpkg.com/three@0.142.0/examples/js/utils/BufferGeometryUtils.js"></script>

高度マップの生成

グレーの土台は以下の手順で作成しました。

  1. 2次元の高度マップを生成する。
  2. 各座標毎に高さの分だけ1x1x1のキューブを積み重ねる。

高度マップはDiamond-Squareアルゴリズムで生成しました。runDiamondSquareAlgorithm関数がこの処理に対応します。

以下の記事に少しだけ詳しめの説明があります。よろしければそちらもご覧ください。

imai1.hatenablog.com

生成した高度マップに対してキューブを積み重ねる処理はmakeMapMesh関数にまとめました。

function makeMapMesh(map, bottom) {
  const boxGeometries = [];
  function addBoxGeometry(x, y, z) {
    const boxGeometry = new THREE.BoxGeometry(1, 1, 1);
    const translatedGeometry = boxGeometry.translate(x, y, z);
    boxGeometries.push(translatedGeometry);
  }

  for (let y = 0; y < map.length; ++y) {
    for (let x = 0; x < map[y].length; ++x) {
      const px = x - map[y].length / 2;
      const py = y - map.length / 2;
      const height = Math.floor(map[y][x]);
      for (let pz = Math.ceil(bottom); pz < height; ++pz) {
        addBoxGeometry(px, py, pz);
      }
    }
  }

  const combinedGeometry = THREE.BufferGeometryUtils.mergeBufferGeometries(boxGeometries);
  const lambertMaterial = new THREE.MeshLambertMaterial({color: 0xEAEFF2});
  const mapMesh = new THREE.Mesh(combinedGeometry, lambertMaterial);

  return mapMesh;
}

海の生成

青色の海っぽい部分はmakeSeaMesh関数で生成しています。

function makeSeaMesh(length, bottom) {
  const boxGeometries = [];
  for (let z = -1; z > bottom; z--) {
    const boxGeometry = new THREE.BoxGeometry(length - 0.1, length - 0.1, 1);
    const translatedGeometry = boxGeometry.translate(-0.5, -0.5, z - 0.05);
    boxGeometries.push(translatedGeometry);
  }
  const combinedGeometry = THREE.BufferGeometryUtils.mergeBufferGeometries(boxGeometries);
  const material = new THREE.MeshBasicMaterial({color: 0x000033, side: THREE.DoubleSide});
  material.opacity = 0.6;
  material.transparent = true;
  const seaMesh = new THREE.Mesh(combinedGeometry, material);
  return seaMesh;
}

地面の幅×高さ×1のキューブを海面の高さから下に並べる関数です。

1マス分の深さの地面は少し薄めの紺に塗られて透過し、2マス分以上の深さの地面はやや濃い紺色に塗られて透過するようになっています。

3マス分以上でも本当は深さに比例して濃くなっていくようにしたかったのですが、一旦ver.0.1ではこんな感じです。

人の配置

人を配置する処理は以下の部分です。

const materials = [];
function addSpriteMeshes(scene, map, numTrial) {
  for (let i = 0; i < numTrial; i++) {
    const y = Math.floor(Math.random() * map.length);
    const x = Math.floor(Math.random() * map[y].length);
    if (map[y][x] < 0) {
      continue;
    }

    const px = x - map[y].length / 2;
    const py = y - map.length / 2;
    const pz = Math.floor(map[y][x]);
    const material = materials[Math.floor(Math.random() * materials.length)];
    const sprite = new THREE.Sprite(material);
    sprite.position.set(px, py, pz);
    scene.add(sprite);
  }
}

Three.jsのスプライトの機能を使ってランダムな座標に配置していきます。materialsには後でSpriteMaterialをセットします。

環境設定

カメラ、ライト、フォグなどの3DCGプログラミングの基本的な設定はinitializeSceneにまとめてあります。このプログラムのためだけの、位置や角度などが完全固定の処理を単に関数にまとめたものになります。

function initializeScene() {
  const scene = new THREE.Scene();
  
  scene.fog = new THREE.Fog(0x0, 120, 220);

  const camera = new THREE.OrthographicCamera(-16, +16, 10, -10, 1, 1000);
  camera.position.set(-90, -90, 60);
  //camera.rotation.set(Math.PI / 4, 0, -Math.PI / 4);
  camera.up.set(1, 1, 1);
  camera.lookAt(new THREE.Vector3(0, 0, 0));
  scene.add(camera);
  
  const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5);
  directionalLight.position.set(-10, 15, 50);
  scene.add(directionalLight);
  
  const directionalLightTarget = new THREE.Object3D();
  scene.add(directionalLightTarget);
  directionalLight.target = directionalLightTarget;

  const ambientLight = new THREE.AmbientLight(0xffffff, 0.5)
  scene.add(ambientLight);

  return [scene, camera];
}

テクスチャ読み込みと実行

最後に全体の実行部分です。

まずmain関数に、レンダラの設定等をまとめました。

function main() {
  const WIDTH = window.innerWidth;
  const HEIGHT = window.innerHeight;

  const renderer = new THREE.WebGLRenderer({antialias:true});
  renderer.setSize(WIDTH, HEIGHT);
  renderer.setClearColor(0xDDDDDD, 1);
  document.body.appendChild(renderer.domElement);

  const [scene, camera] = initializeScene();
  
  const exponent = 5;
  const length = Math.pow(2, exponent) + 1;
  const amplitude = 10.0;
  const map = runDiamondSquareAlgorithm(exponent, amplitude);

  scene.add(makeMapMesh(map, -amplitude));

  scene.add(makeSeaMesh(length, -amplitude));

  addSpriteMeshes(scene, map, 50);

  /*
  function render() {
    requestAnimationFrame(render);
    renderer.render(scene, camera);
  }
  render();
  */
  renderer.render(scene, camera);
}

テクスチャの読み込みは非同期処理が必要なので、Promiseを利用します。読み込みが完了したらSpriteMaterialを生成してmainを呼び出します。

const textureURLs = [
  "(データ)URLを列挙。長いので省略。"
];

function loadTexture(url) {
  return new Promise(resolve => {
    new THREE.TextureLoader().load(url, resolve);
  });
}

function loadTextures() {
  const promises = [];
  for (const textureURL of textureURLs) {
    promises.push(loadTexture(textureURL));
  }

  return Promise.all(promises);
}

// ...

loadTextures().then(textures => {
  for (const texture of textures) {
    materials.push(new THREE.SpriteMaterial({
      map: texture
    }));
  }
  main();
});

画像はいらすとやさんからお借りしました。

将来課題

以下を改善できたら良いなと思います。

  • 海を深さに比例して不透過になるよう修正。
  • 土台の見た目を改善。