Chapter 100

3Dの表現

miku
miku
2021.11.20に更新

この章では3Dの表現について扱う。ピクセルの集まりである画面に奥行きなどは存在しないので、3Dの表現をしようと思うと、うまく遠近感を出して表現する必要がある。

z軸による拡大縮小

今までは左右を表すx軸、上下を表すy軸があったが、それに加えて手前か奥を表すz軸を用意する。つまり座標 (x, y)(x, y, z) に拡張する。

遠くにあるものほど小さく見えるのだから、z の値に応じてオブジェクトを拡大縮小すればいい。

とりあえず z の値が大きくなるほどオブジェクトを縮小することにしよう。あとは z の値に対する比率、たとえば z の値が 1 から 2 に変わったときにどれだけ小さくなるかだが、簡単に計算するために 1/z を縮小率とする。たとえば z の値を 1, 2, 3, 4 と変えていくと、オブジェクトのサイズが 1/1, 1/2, 1/3, 1/4 と変わっていく。

function setup() {
  createCanvas(windowWidth, windowHeight);
  rectMode(CENTER);

  const ow = 100;
  const oh = 80;

  push();
  stroke(240);
  noFill();
  rect(width / 2, height / 2, ow, oh);
  pop();

  for (let i = 0; i < 10; i++) {
    const x = random(width);
    const y = random(height);
    const z = random(1, 10);
    const scale = 1 / z;
    rect(x, y, ow * scale, oh * scale);
  }
}

z1~10 の矩形を複数描画する作例。大きさの比較として、中央に scale = 1 のストロークだけの矩形を描画している。

消失点

z が大きくなるほどオブジェクトが小さくなっていくが、このとき座標もある一点に近づくようにするとより遠近感が出る。このような点を消失点と呼ぶ。

1 / z で計算したスケール値を座標の各成分に掛けると最終的に原点に近づくので、原点を消失点にすると扱いやすい。原点を特定の座標する場合は translate() を利用して座標を移動させればいい。

function setup() {
  createCanvas(windowWidth, windowHeight);
  rectMode(CENTER);

  const ow = 100;
  const oh = 80;

  translate(width / 2, height / 2);
  for (let i = 0; i < 10; i++) {
    const z = random(1, 5);
    const scale = 1 / z;

    const x = random(-width / 2, width / 2) * scale;
    const y = random(-height / 2, height / 2) * scale;

    rect(x, y, ow * scale, oh * scale);
  }
}

消失点のアニメーション

const ow = 100;
const oh = 80;
let objects;

function setup() {
  createCanvas(windowWidth, windowHeight);
  rectMode(CENTER);

  objects = [];

  for (let i = 0; i < 10; i++) {
    const x = random(-width / 2, width / 2);
    const y = random(-height / 2, height / 2);
    const z = random(1, 5);

    objects.push({ x, y, z });
  }
}

function draw() {
  clear();

  translate(width / 2, height / 2);

  objects.forEach((o) => {
    const scale = 1 / o.z;
    const x = o.x * scale;
    const y = o.y * scale;
    const tw = ow * scale;
    const th = oh * scale;

    rect(x, y, tw, th);

    o.z += 0.1;
    if (o.z > 20) {
      o.z = 1;
    }
  });
}

透視投影

これまでは対象となるオブジェクトの z の値に依存して拡大縮小を行った。ここで映す側であるカメラというものを実装したい。

たとえば、オブジェクトの座標が固定でも、カメラの座標が後ろに下がればオブジェクトは小さくなるようにしたい。このような、カメラの座標とオブジェクトの座標に応じた縮小率を計算するために透視投影という技法を利用する。

透視投影ではカメラ、オブジェクト、スクリーンという3つの座標を用意する。カメラとスクリーンは z だけを用意すればいい。そしてカメラからオブジェクトまでの範囲の中で、スクリーンがどの位置にあるのかを0~1の割合で求める。これは正規化をする norm() で計算ができ、この値がオブジェクトの縮小率になる。

const s = norm(screen, camera, target.z)
obj.x *= s;
obj.y *= s;

正規化の計算だけなので非常にシンプルになるが、これはスクリーンのz座標がカメラとオブジェクトの間に必ずあるという制限が必要になる。なので、(camera <= screen && screen <= obj.z)false になるならオブジェクトを描画しないなどの処理を加えてもいい。

その他、特定のz座標をスクリーンの座標として固定すると考えれば screen 変数は不要になる。たとえば z = 0 をスクリーンにする場合で、カメラが z < 0、オブジェクトが z > 0 にあるときは norm(0, camera, obj.z) になる。

let camera, target;

function setup() {
  createCanvas(windowWidth, windowHeight);
  rectMode(CENTER);

  camera = -10;
  target = { x: 0, y: 0, w: 100, h: 100, z: 10 };
}

function draw() {
  clear();
  translate(width / 2, height / 2);

  const s = norm(0, camera, target.z);
  rect(target.x * s, target.y * s, target.w * s, target.h * s);

  target.z += 0.05;
}

screen = 0 にした場合の透視投影を行う作例。

透視投影のアニメーション

let camera, targets;

function setup() {
  createCanvas(windowWidth, windowHeight);
  rectMode(CENTER);

  camera = -1;

  targets = [];
  for (let i = 0; i < 30; i++) {
    const x = random(-width / 2, width / 2);
    const y = random(-height / 2, height / 2);
    const w = 100;
    const h = 80;
    const z = random(10, 100);
    targets.push({ x, y, w, h, z });
  }
}

function draw() {
  clear();
  translate(width / 2, height / 2);

  targets.forEach((t) => {
    t.z -= 0.1;
    if (t.z < 0) {
      t.z = 100;
    }
    const s = norm(0, camera, t.z);
    rect(t.x * s, t.y * s, t.w * s, t.h * s);
  });
}

複数のオブジェクトを用意して、z の値を毎フレーム減らして画面に近づける。z < 0 になるとスクリーンより内側にオブジェクトが来たということで表示ができないので、また奥に戻してループさせる作例。

実行結果を見てみると、ときどき重なりがおかしくなる。これは奥にあるものほど先に描画をしなければならないのにその処理を行っていないのでz軸によるソートが必要になる。

z軸によるソート

let camera, targets;

function setup() {
  createCanvas(windowWidth, windowHeight);
  rectMode(CENTER);

  camera = -1;

  targets = [];
  for (let i = 0; i < 30; i++) {
    const x = random(-width / 2, width / 2);
    const y = random(-height / 2, height / 2);
    const w = 100;
    const h = 80;
    const z = random(10, 100);
    targets.push({ x, y, w, h, z });
  }
}

function draw() {
  clear();
  translate(width / 2, height / 2);

  targets.sort((a, b) => b.z - a.z);
  targets.forEach((t) => {
    t.z -= 0.1;
    if (t.z < 0) {
      t.z = 100;
    }
    const s = norm(0, camera, t.z);
    rect(t.x * s, t.y * s, t.w * s, t.h * s);
  });
}

targets.sort((a, b) => b.z - a.z);z の降順にオブジェクトを並べて、奥にあるものほど先に描画をするようにして重なりがおかしい問題を解消した作例。