Open9

three.jsでマイクラ風お手軽建築アプリを作りたい

のんのん

マイクラで建築をする時はクリエイティブモードで試しに作ってからサバイバルで実際に作ることが多いけど、ワールドを切り替えたりするのがめんどくさいので、マイクラでの建築が楽になるようなアプリを作りたい。
ブラウザでできればマイクラとブラウザ両方開いて見ながらできるし、印刷すれば立派な設計図になる..はず。

のんのん

準備

viteとthree.jsで作る。

まずプロジェクトを作成する。

yarn create vite appname --typescript

いらない部分は全部削除する。

// main.ts

import './style.css';
// index.html

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + TS</title>
  </head>
  <body>
    <div id="canvas"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

index.htmlのdivのidはcanvasに変えておく。

three.jsを入れる。UIデバッグもしたいので、lil-guiも入れる。

yarn add three
yarn add lil-gui

main.jsでthree.jsをインポートして、コンソールに出力してthree.jsが使えるようになっているか確認。

import '/style.css';

import * as THREE from 'three'; 

console.log(THREE);

three.jsのオブジェクトが無事にコンソールに出力できていた。スクショは取っていない。
これで開発準備完了:)

のんのん

地面を作る

ブロックを置くために地面が欲しいので、地面を作る。
地面はただの平面でいいから、planeGeometryを使う。

three.jsでは

  • シーン
  • カメラ
  • レンダラー

が絶対に欲しいので、まずはこれを追加する。

// シーンを追加
const scene = new THREE.Scene();

// カメラを追加
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.z = -10;

// レンダラーを追加
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);

画面が全部黒くなれば画面全体にレンダラーが配置されている。はず。

カメラが初期位置にあると都合が悪いのでちょっとバックしてもらう。

真っ黒でカメラがバックしてもわからないので、さっそくplaneGeometryを設置する。

ライトを使うのがめんどくさいので、ライトがいらないMeshBasicMaterialを使う。

// 地面だよ
const planeGeometory = new THREE.PlaneGeometry(5, 5, 1, 1);
const planeMaterial = new THREE.MeshNormalMaterial({
  side: THREE.DoubleSide
});
const plane = new THREE.Mesh(planeGeometory, planeMaterial);
plane.rotation.x = -Math.PI * 0.5;
scene.add(plane);

シーンにaddするのを忘れない。

縦になっているので、-90°回転させる。

またスクショ取ってないけど、これで地面ができたはず:)

のんのん

OrbitControlsの追加

OrbitControlsを使用すると、ドラッグでカメラ操作をできるようになる。
https://threejs.org/docs/index.html?q=Orbit#examples/en/controls/OrbitControls

とても便利なので導入したい。

import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";

const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.1;

function animate() {
   ...
   controls.update();
   ...
}

.enableDampingをtrueにすると滑らかな動きになる。

.dampingFactorを大きくすると動きが軽い感じになる。

.enableDampingをtrueにしたら、アニメーションループで.update()しないといけない。

たぶんこれだけで動く。カンタン。

のんのん

ブロックを置く

地面ができたので、次はブロックを置いてみる。

boxGeometryをシーンに追加してみる。

マイクラのオブジェクトは全部同じサイズの立方体の中に納まるから、

1×1×1のboxGeometoryをどんどん追加していけば良さそう。

ハーフブロックは高さが半分のサイズだから1×0.5×1のサイズになると思う。

// ブロックだよ
const boxGeometry = new THREE.BoxGeometry(1, 1, 1);
const boxMaterial = new THREE.MeshBasicMaterial();
const box = new THREE.Mesh(boxGeometry, boxMaterial);
box.position.y = 0.5;
scene.add(box);

このままだと地面にめり込んでいるので、地面の高さに合わせる。

地面もブロックも白だと見にくいから、ブロックだけ色を変える。

置いてみるだけなので、座標を指定してfor文で回して配置してみる。

const blockPositions = [
  { x: 0, y: 0.5, z: 0 },
  { x: 1, y: 0.5, z: 0 },
  { x: 0, y: 0.5, z: 2 },
  { x: 1, y: 0.5, z: 2 },
  { x: 1, y: 0.5, z: 1 },
  { x: 1, y: 1.5, z: 1 },
  { x: 1, y: 2.5, z: 1 },
  { x: 1, y: 3.5, z: 1 },
  { x: 1, y: 2.5, z: 1 },
  { x: 0, y: 2.5, z: 0 },
  { x: 0, y: 2.5, z: 2 },
];

for (let i = 0; i < blockPositions.length; i++) {
  const boxMaterial = new THREE.MeshBasicMaterial({
    color: 0x00ff7f,
  });
  const box = new THREE.Mesh(boxGeometry, boxMaterial);
  box.position.set(
    blockPositions[i].x,
    blockPositions[i].y,
    blockPositions[i].z
  );
  scene.add(box);
}

境目が全然わからないので、boxHelperも追加する。

const boxHelper = new THREE.BoxHelper(box, 0x000000);
boxHelper.geometry.computeBoundingBox();
boxHelper.update();
scene.add(box, boxHelper);

マイクラっぽく置けてる気がする:)

のんのん

ホバーしたところに枠を表示する -カーソルの座標を取得

マイクラはブロックを置けるところにカーソルを当てると枠が表示される。

実現するために必要なことは、

  • カーソルの座標
  • どのオブジェクトにホバーしているか

の2つの情報が欲しい。

まずカーソルの座標の取得

function onMouseMove(event: MouseEvent) {
  const mouse = new THREE.Vector2();
  console.log(event.clientX, event.clientY);
}
window.addEventListener("mousemove", onMouseMove);

取得はできていそうだけど、このままだと原点がcanvasの左上になっている。

canvasの中心を原点にして、さらに値を-1~1の範囲になるようにする。

正規化っていうんだって

function onMouseMove(event: MouseEvent) {
  const mouse = new THREE.Vector2();
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
  console.log(event.clientX, event.clientY);
}

わかりやすい図を載せてくれているページがありました。

【Three.js】マウスに追従するライト | デザインおしゃれ手帳

桁がたくさんでよくわからないけどたぶんできている:)

のんのん

ホバーしたところに枠を表示する -オブジェクトの交差

気づいたら1か月も経っていた。

マウスの座標とオブジェクトが交差したかをチェックする。

raycasterでできるらしい。
https://threejs.org/docs/index.html?q=ray#api/en/core/Raycaster

交差したオブジェクトを全て取得してくるみたいなので、一番手前だけを出力してみる。

const raycaster = new THREE.Raycaster();
  // 正規化された座標と、カメラを渡す
  raycaster.setFromCamera(mouse, camera);
  // 交差判定
  // シーンに追加したオブジェクトたちとの交差を判定したいので、scene.childrenを渡す
  const intersects = raycaster.intersectObjects(scene.children, false);

  console.log(intersects[0]);

boxHelperが付いていると交差した面が取得できないから、シーンから削除しておく

mousemoveイベントだとちょっと動かすたびにめっちゃログが出て邪魔なので、一旦クリックイベントに変えておく

faceプロパティが交差したオブジェクトの詳細らしい。

normalがホバーしたところの法線ベクトルだから、これを加算すれば隣に枠を表示できそう:)

のんのん

ホバーしたところに枠を表示する -法線ベクトルを加算

また1か月近く経っている:(

今のままだと軸がどっち方向かわかりにくいので、軸の向きを表示する

https://threejs.org/docs/index.html?q=axe#api/en/helpers/AxesHelper

グリッドも表示しておく

https://threejs.org/docs/index.html?q=grid#api/en/helpers/GridHelper

地面で見にくいので半透明にしておく

地面のサイズをグリッドのサイズに合わせておく

// グリッドを表示
const gridHelper = new THREE.GridHelper(10, 10, 0xffff00);
scene.add(gridHelper);

// 軸を表示
const axesHelper = new THREE.AxesHelper(5);
scene.add(axesHelper);

// 地面だよ
...
const planeMaterial = new THREE.MeshBasicMaterial({ side: THREE.DoubleSide, opacity: 0.1, transparent: true });
...

グリッドとずれているので合わせておく

オブジェクトの座標はオブジェクトの中心の座標だから、boxのサイズの半分の0.5ずつずれているということかなたぶん

// ブロックだよ
...
box.position.set(
    blockPositions[i].x + 0.5,
    blockPositions[i].y,
    blockPositions[i].z + 0.5
);
...

枠を表示するのに、枠オブジェクトをいちいちaddしたりremoveしたりすると大変

three.jsのexampleにやりたい動作をしているものがあった

カーソルに合わせてboxを移動しているらしい

これと同じように実装する

https://threejs.org/examples/#webgl_interactive_voxelpainter

枠っぽくしたいので、boxのワイヤーフレームを表示させる

// 枠を表示したい
const frameBoxGeometry = new THREE.BoxGeometry(1, 1, 1);
const frameBoxMaterial = new THREE.MeshBasicMaterial({
  wireframe: true,
  color: 0xffffff,
});
scene.add(frameBox);

設置したブロックと地面とだけ交差判定したいので、ブロックと地面だけを配列に格納しておく

const objects = [];

なんか見づらいので、仮置きのボックスを1つにする

ボックスをクリックした時に、クリックしたオブジェクトのpositionに法線ベクトルを足してみる

// ボックスにクリックした時
if(!intersect.point || !intersect.face) return; // 何もないところをクリックしたらreturn
frameBox.position.copy(intersect.object.position);
frameBox.position.add(intersect.face.normal);

地面はオブジェクトの座標が0なので、地面に設置したときのY座標は0.5

グリッドに合わせて表示するには、座標を0.5,1.5,2.5…みたいな感じにする必要があるので、

絶対値と四捨五入とかでいい感じにする

クリックした座標 - クリックした座標を四捨五入した値

をして、0.5より大きかったら+の方向、違うなら-の方向に箱を置くよ

みたいなことをしているはず

書いたのが昔過ぎて忘れましたXD

function roundGreaterThanHalf(position: number) {
  if (Math.abs(position - Math.round(position)) >= 0.5) {
    return Math.floor(position) - 0.5;
  } else {
    return Math.floor(position) + 0.5;
  }
}
if (intersect.object.name === "ground") {
  // 地面をクリックした時
  frameBox.position.set(
    roundGreaterThanHalf(intersect.point.x),
    0.5,
    roundGreaterThanHalf(intersect.point.z)
  );
  return;
}

たぶん期待通りの動作をしている:)

のんのん

ブロックの配置

ブロックを置きたいところにカーソルを合わせて右クリックをすると、boxを配置するようにしたい

右クリックしたらframeBoxがあるところにboxを配置する

function addBlock(event: MouseEvent) {
  const box = new THREE.Mesh(boxGeometry, boxMaterial);
  box.position.copy(frameBox.position);
  box.name = `block${objects.length}`;
  scene.add(box);
  objects.push(box);
}

window.addEventListener("contextmenu", addBlock, false);

ホバーしているところにフレームが移動して

フレームのpositionをコピーして

同じ位置に新しいboxをaddするようにした:)