3Dエンジンを作りたい

ゲーム作りたいですか?作りたいですよね。 私はゲームを作りたいとは思っていませんが、ゲームを作るための技術を学びたいと思っています。

そこで、まずは3Dをいちから描画できるようになるための技術をやってみることにしました。

データ構造を考える

3Dなので、縦横奥行きの三次元を表すことができるデータが必要です。3Dデータは基本的に頂点の集まりであるため、頂点をデータとして表す必要があります。x, y, z をもつデータがあればよいでしょう。また、頂点同士を繋いで面を表現します。これもデータとして必要です。面を表すにはは最低でも3点必要ですね。アプリケーションによっては4点以上で面を構成する場合もあるようですが、場合分けをしたりするのが面倒なので、3点しかないものとします。

type Vertex = { x: number, y: number, z: number };
type Face = { v1: Vertex, v2: Vertex, v3: Vertex };

とりあえず、箱を用意する

表示する前に、データを用意する必要があります。箱を用意しましょう。見づらくて申し訳ありませんが、これが箱を表すデータです。w, h, dがそれぞれ幅、高さ、奥行きを表します。pは中心点です。箱には6枚の面がありますが、1枚の面は4つの頂点で構成されています。上でも書きましたが、今回のプログラムでは、3点で1枚の面を表現します。したがって、四角形の面を表現するには、2枚の面が必要になるため、箱を表現するには12枚の面が必要です。

const w = 10, h = 10, d = 10, p = { x: 0, y: 0, z: 0 };
const cube: Face[] = [
  { v1: { x: p.x - w, y: p.y - h, z: p.z - d }, v2: { x: p.x + w, y: p.y - h, z: p.z - d}, v3: { x: p.x - w, y: p.y - h, z: p.z + d } },
  { v1: { x: p.x + w, y: p.y - h, z: p.z - d }, v2: { x: p.x + w, y: p.y - h, z: p.z + d}, v3: { x: p.x - w, y: p.y - h, z: p.z + d } },
  { v1: { x: p.x - w, y: p.y - h, z: p.z + d }, v2: { x: p.x + w, y: p.y - h, z: p.z + d}, v3: { x: p.x - w, y: p.y + h, z: p.z + d } },
  { v1: { x: p.x - w, y: p.y + h, z: p.z + d }, v2: { x: p.x + w, y: p.y - h, z: p.z + d}, v3: { x: p.x + w, y: p.y + h, z: p.z + d } },
  { v1: { x: p.x + w, y: p.y - h, z: p.z - d }, v2: { x: p.x + w, y: p.y + h, z: p.z - d}, v3: { x: p.x + w, y: p.y - h, z: p.z + d } },
  { v1: { x: p.x + w, y: p.y + h, z: p.z - d }, v2: { x: p.x + w, y: p.y + h, z: p.z + d}, v3: { x: p.x + w, y: p.y - h, z: p.z + d } },
  { v1: { x: p.x - w, y: p.y + h, z: p.z - d }, v2: { x: p.x - w, y: p.y + h, z: p.z + d}, v3: { x: p.x + w, y: p.y + h, z: p.z - d } },
  { v1: { x: p.x + w, y: p.y + h, z: p.z - d }, v2: { x: p.x - w, y: p.y + h, z: p.z + d}, v3: { x: p.x + w, y: p.y + h, z: p.z + d } },
  { v1: { x: p.x - w, y: p.y - h, z: p.z - d }, v2: { x: p.x - w, y: p.y - h, z: p.z + d}, v3: { x: p.x - w, y: p.y + h, z: p.z + d } },
  { v1: { x: p.x - w, y: p.y - h, z: p.z - d }, v2: { x: p.x - w, y: p.y + h, z: p.z + d}, v3: { x: p.x - w, y: p.y + h, z: p.z - d } },
  { v1: { x: p.x - w, y: p.y - h, z: p.z - d }, v2: { x: p.x - w, y: p.y + h, z: p.z - d}, v3: { x: p.x + w, y: p.y - h, z: p.z - d } },
  { v1: { x: p.x + w, y: p.y - h, z: p.z - d }, v2: { x: p.x - w, y: p.y + h, z: p.z - d}, v3: { x: p.x + w, y: p.y + h, z: p.z - d } },
];

これを、canvasに描画する

それでは、箱をcanvasに描画しましょう。canvasに線を描画する方法などは説明しませんが、まあ雰囲気でわかると思います。まずは、とにかくなにかを描画したいという意志のもと、z座標をオミットして、x, y座標のデータのみをcanvasに表現してみるという手法です。width/2とかheight/2とかやっているのは、座標の原点(x = 0, y = 0)をcanvasの中央にしたかったからです。画面の中央に箱が表示されることを目指しています。

const canvas = document.querySelector("canvas")!;
const context = canvas.getContext("2d")!;
const width = canvas.width;
const height = canvas.height;

cube.forEach(face => {
  context.beginPath();
  context.strokeStyle = "black";
  context.moveTo((width / 2) + face.v1.x, (height / 2) - face.v1.y);
  context.lineTo((width / 2) + face.v2.x, (height / 2) - face.v2.y);
  context.lineTo((width / 2) + face.v3.x, (height / 2) - face.v3.y);
  context.closePath();
  context.stroke();
});

描画したぞ

わかりますかね・・・?黒い線で四角に斜め線が入っていますね。失敗ではありません。これで成功です。やりました。3Dのキューブを真横から見た映像を描画できました。

奥行きが感じられませんね。それもそのはず、Orthographic cameraと呼ばれる、奥行き情報を捨てたカメラで撮影しているからです。(z座標のデータを捨てたのは、つまり奥行き情報を捨てているのです)

ほんとに箱を描画しているのか?そう思う気持ちもわかります。先を急ぎましょう。

箱を回転させてみる

ただの四角でないことを理解するためには、箱を回転させればよいでしょう。回転を見れば3Dっぽいのがわかると思います。まずは、箱を回転させるための計算を書きましょう。これです。targetは回転させたい点、centerは回転の中心、thetaとphiは回転角です。説明はできません。インターネットに書いてあったものなのでさっぱりです。まあ、ともかくとして、回転させてみましょう。

// 回転関数
function rotateVertex(target: Vertex, center: Vertex, theta: number, phi: number) {
  const ct = Math.cos(theta), st = Math.sin(theta), cp = Math.cos(phi), sp = Math.sin(phi);

  const x = target.x - center.x,
        y = target.y - center.y,
        z = target.z - center.z;

  target.x = ct * x - st * cp * z + st * sp * y + center.x;
  target.z = st * x + ct * cp * z - ct * sp * y + center.z;
  target.y = sp * z + cp * y + center.y;
}

// cubeを回転させるには、すべての頂点を座標0, 0, 0を中心に回転させればよい。
cube.forEach(face => {
  rotateVertex(face.v1, { x: 0, y: 0, z: 0 }, Math.PI / 180, Math.PI / 300);
  rotateVertex(face.v2, { x: 0, y: 0, z: 0 }, Math.PI / 180, Math.PI / 300);
  rotateVertex(face.v3, { x: 0, y: 0, z: 0 }, Math.PI / 180, Math.PI / 300);
});

アニメーションさせる

と、いっても、アニメーションさせないと見た目がわからないですよね。雑に書くとこうです。

// 回転も、setTimeoutなどを使って、定期的に行いましょう。

function clear() {
  context.fillStyle = "#fff";
  context.fillRect(0, 0, width, height);
}

type Point = {x: number, y: number}
function draw(v1: Point, v2: Point, v3: Point) {
  context.beginPath();
  context.strokeStyle = "black";
  context.moveTo((width / 2) + v1.x, (height / 2) - v1.y);
  context.lineTo((width / 2) + v2.x, (height / 2) - v2.y);
  context.lineTo((width / 2) + v3.x, (height / 2) - v3.y);
  context.closePath();
  context.stroke();
}

function render() {
  clear();
  cube.forEach(face => draw(face.v1, face.v2, face.v3));
  requestAnimationFrame(render);
}

render();

回転したぞ

できました。

いったんまとめ

雑ではありますが、これが3Dを描画する方法です。やる気が続けば、次は奥行きをもつ箱も描画できるようにしていければと思います。

ここまでのコードは以下のコミットにあります。

github.com

以上です。よろしくお願いいたします。