# のカメラ

この記事はThree.jsの連載記事の1つです。 最初の記事はThree.jsの基礎知識です。 まだ読んでいない場合、そこから始めると良いかもしれません。

three.jsでのカメラの話をしましょう。 最初の記事でいくつか取り上げましたが、ここではもっと詳しく取り上げます。

`PerspectiveCamera（透視投影カメラ）` はthree.jsで最も一般的なカメラで、今までの記事で使ってきました。 遠くのものが近くのものよりも小さく見える3Dビューを提供します。

`PerspectiveCamera` は 錐台 を定義します。 錐台とは先端が切り取られたピラミッドのような3D形状の事です。 つまり、cube(立方体)、cone(円錐体)、sphere(球体)、cylinder(円柱)、frustum(錐台)は全て異なる種類の固体名です。

cube
cone
sphere
cylinder
frustum

私はこの事を何年も知らなかったです。 どこかの本やページで 錐台 について書かれていると目が点になります。 錐台が固体名と理解すると、それらの記述を急に理解できるようになりました 😅

`PerspectiveCamera` には4つのプロパティをもとに錐台が定義されています。 `near` は錐台の正面がどこから始まるかを定義します。 `far` は錐台が終了する場所です。 `fov` は視野角で、カメラから `near` 単位で指定された視野角を得るために正しい高さが計算され、錐台の前面と背面の高さを定義します。 `aspect` は錐台の前面と背面の幅です。 錐台の幅は高さにaspectを掛けたものです。

前回の記事から地面となる平面、球体、立方体のあるシーンを利用し、カメラの設定を調整してみましょう。

`near` と `far` の設定用に `MinMaxGUIHelper` を作成します。 `far` が常に `near` よりも大きい値になるようにします。 MinMaxGUIHelperは `min` と `max` のプロパティがあり、lil-guiで調整します。 GUIで値を調整すると2つのプロパティに設定されます。

    class MinMaxGUIHelper {
      constructor(obj, minProp, maxProp, minDif) {
        this.obj = obj;
        this.minProp = minProp;
        this.maxProp = maxProp;
        this.minDif = minDif;
      }
      get min() {
        return this.obj[this.minProp];
      }
      set min(v) {
        this.obj[this.minProp] = v;
        this.obj[this.maxProp] = Math.max(this.obj[this.maxProp], v + this.minDif);
      }
      get max() {
        return this.obj[this.maxProp];
      }
      set max(v) {
        this.obj[this.maxProp] = v;
        this.min = this.min;  // this will call the min setter
      }
    }

これでGUIを以下のように設定できます。

    function updateCamera() {
      camera.updateProjectionMatrix();
    }

    const gui = new GUI();
    gui.add(camera, 'fov', 1, 180).onChange(updateCamera);
    const minMaxGUIHelper = new MinMaxGUIHelper(camera, 'near', 'far', 0.1);
    gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
    gui.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far').onChange(updateCamera);

カメラ設定の変更時、カメラの `updateProjectionMatrix` 関数を呼び出す必要があります。 `updateCamera` という関数を作り、それをlil-gui変更時に呼び出すようにします。

ここをクリックして別のウィンドウで開きます

値を調整すると動作が確認できます。 `aspect` を調整したい場合は、新しいウィンドウでサンプルを開いてからウィンドウサイズを変更して下さい。

それでもまだ少し見づらいので、2つのカメラを持つサンプルに変えます。 1つ目のカメラは上記で見たシーンを表示し、2つ目のカメラは1つ目のカメラが描画してるシーンを見ている別のカメラとし、そのカメラの錐台を表示します。

そのためにthree.jsのシザー関数を利用します。 シザー機能を使い、カメラを2台並べて2つのシーンを描画するように変更してみましょう。

まず、HTMLとCSSを使って2つの並んでるDOM要素を定義してみましょう。 両方のカメラが簡単に独自の `OrbitControls` を持てるようにします。

    <body>
      <canvas id="c"></canvas>
    +  <div class="split">
    +     <div id="view1" tabindex="1"></div>
    +     <div id="view2" tabindex="2"></div>
    +  </div>
    </body>

このview1とview2をキャンバスの上に重ねて表示させます。

    .split {
      position: absolute;
      left: 0;
      top: 0;
      width: 100%;
      height: 100%;
      display: flex;
    }
    .split>div {
      width: 100%;
      height: 100%;
    }

次に `CameraHelper` を追加します。 `CameraHelper` は `Camera` の錐台を描画します。

    const cameraHelper = new THREE.CameraHelper(camera);

    ...

    scene.add(cameraHelper);

view1とview2のDOM要素をquerySelectorしましょう。

    const view1Elem = document.querySelector('#view1');
    const view2Elem = document.querySelector('#view2');

既存の `OrbitControls` をview1にのみ反応するようにします。

    -const controls = new OrbitControls(camera, canvas);
    +const controls = new OrbitControls(camera, view1Elem);

2つ目の `PerspectiveCamera` と `OrbitControls` を作ってみましょう。 2つ目の `OrbitControls` は2つ目のカメラに関連付けし、view2から入力を取得します。

    const camera2 = new THREE.PerspectiveCamera(
      60,  // fov
      2,   // aspect
      0.1, // near
      500, // far
    );
    camera2.position.set(40, 10, 30);
    camera2.lookAt(0, 5, 0);

    const controls2 = new OrbitControls(camera2, view2Elem);
    controls2.target.set(0, 5, 0);
    controls2.update();

最後にキャンバスの一部だけをレンダリングするために、シザー機能を使い各カメラの視点からシーンをレンダリングします。

ここにDOM要素を渡すと、キャンバスに重なる矩形を計算する関数があります。 その矩形にシザーとビューポートを設定し、アスペクト比を返します。

    function setScissorForElement(elem) {
      const canvasRect = canvas.getBoundingClientRect();
      const elemRect = elem.getBoundingClientRect();

      // compute a canvas relative rectangle
      const right = Math.min(elemRect.right, canvasRect.right) - canvasRect.left;
      const left = Math.max(0, elemRect.left - canvasRect.left);
      const bottom = Math.min(elemRect.bottom, canvasRect.bottom) - canvasRect.top;
      const top = Math.max(0, elemRect.top - canvasRect.top);

      const width = Math.min(canvasRect.width, right - left);
      const height = Math.min(canvasRect.height, bottom - top);

      // setup the scissor to only render to that part of the canvas
      const positiveYUpBottom = canvasRect.height - bottom;
      renderer.setScissor(left, positiveYUpBottom, width, height);
      renderer.setViewport(left, positiveYUpBottom, width, height);

      // return the aspect
      return width / height;
    }

この関数を使って `render` 関数でシーンを2回描画できます。

      function render() {

    -    if (resizeRendererToDisplaySize(renderer)) {
    -      const canvas = renderer.domElement;
    -      camera.aspect = canvas.clientWidth / canvas.clientHeight;
    -      camera.updateProjectionMatrix();
    -    }

    +    resizeRendererToDisplaySize(renderer);
    +
    +    // turn on the scissor
    +    renderer.setScissorTest(true);
    +
    +    // render the original view
    +    {
    +      const aspect = setScissorForElement(view1Elem);
    +
    +      // adjust the camera for this aspect
    +      camera.aspect = aspect;
    +      camera.updateProjectionMatrix();
    +      cameraHelper.update();
    +
    +      // don't draw the camera helper in the original view
    +      cameraHelper.visible = false;
    +
    +      scene.background.set(0x000000);
    +
    +      // render
    +      renderer.render(scene, camera);
    +    }
    +
    +    // render from the 2nd camera
    +    {
    +      const aspect = setScissorForElement(view2Elem);
    +
    +      // adjust the camera for this aspect
    +      camera2.aspect = aspect;
    +      camera2.updateProjectionMatrix();
    +
    +      // draw the camera helper in the 2nd view
    +      cameraHelper.visible = true;
    +
    +      scene.background.set(0x000040);
    +
    +      renderer.render(scene, camera2);
    +    }

    -    renderer.render(scene, camera);

        requestAnimationFrame(render);
      }

      requestAnimationFrame(render);
    }

上記のコードはview1とview2を区別するために、view2をレンダリング時のシーンの背景色を紺色にしています。

また、`render` 関数内で全て更新しているため、`updateCamera` のコードを削除できます。

    -function updateCamera() {
    -  camera.updateProjectionMatrix();
    -}

    const gui = new GUI();
    -gui.add(camera, 'fov', 1, 180).onChange(updateCamera);
    +gui.add(camera, 'fov', 1, 180);
    const minMaxGUIHelper = new MinMaxGUIHelper(camera, 'near', 'far', 0.1);
    -gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
    -gui.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far').onChange(updateCamera);
    +gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near');
    +gui.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far');

片方のviewを使い、もう片方の錐台を見るれるようになりました。

ここをクリックして別のウィンドウで開きます

左側はオリジナルのビュー、右側はカメラの錐台を表示するビューがあります。 マウスで `near`、`far`、`fov` を調整してカメラを動かすと、右側に表示されている錐台の内側だけが左側のシーンに表示されています。

`near` を20くらいに調整すると、錐台に入らずオブジェクトの正面が消えます。 `far` を35以下に調整すると、錐台に入らず地上の平面が消えていきます。

ここで疑問が湧いてきました。 `near` を0.0000000001に `far` を10000000000000に設定し、全てを見えるようにできないでしょうか？ なぜなら、GPUは何かが前後にあるかを判断する精度が高いからです。 その精度は `near` と `far` の間に分散しています。 さらに悪い事にデフォルトではカメラの近くの精度は細かく、カメラから遠い精度は粗くなっています。 単位の値は `near` から始まり、`far` に近づくにつれて徐々に拡大していきます。

上記のサンプルから始めて、20個の球体を1列に挿入するコードに変更してみましょう。

    {
      const sphereRadius = 3;
      const sphereWidthDivisions = 32;
      const sphereHeightDivisions = 16;
      const sphereGeo = new THREE.SphereGeometry(sphereRadius, sphereWidthDivisions, sphereHeightDivisions);
      const numSpheres = 20;
      for (let i = 0; i < numSpheres; ++i) {
        const sphereMat = new THREE.MeshPhongMaterial();
        sphereMat.color.setHSL(i * .73, 1, 0.5);
        const mesh = new THREE.Mesh(sphereGeo, sphereMat);
        mesh.position.set(-sphereRadius - 1, sphereRadius + 2, i * sphereRadius * -2.2);
        scene.add(mesh);
      }
    }

`near` を0.00001に設定してみましょう。

    const fov = 45;
    const aspect = 2;  // the canvas default
    -const near = 0.1;
    +const near = 0.00001;
    const far = 100;
    const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);

値の編集時に0.00001を許容するようにGUIコードを微調整します。

    -gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
    +gui.add(minMaxGUIHelper, 'min', 0.00001, 50, 0.00001).name('near').onChange(updateCamera);

何が起こると思いますか？

ここをクリックして別のウィンドウで開きます

これはGPUがどのピクセルが前後にあるか判断する精度が不足してる時に Zファイティング が発生する例です。

あなたのマシンでは問題が表示されない可能性がありますが、私のマシンでは以下のように表示されます。

1つ目の解決策はどのピクセルが前後にあるかを計算するために、three.jsの別メソッドを使用します。 これは `WebGLRenderer` の作成時に `logarithmicDepthBuffer` を有効にします。

    -const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
    +const renderer = new THREE.WebGLRenderer({
    +  antialias: true,
    +  canvas,
    +  logarithmicDepthBuffer: true,
    +});

これで上手く動くかもしれません。

ここをクリックして別のウィンドウで開きます

これで問題が解決しない場合、この解決策が使えない理由の1つに遭遇した事になります。 その理由は、特定のGPUのみをサポートしているためです。 2018年9月現在、ほとんどのデスクトップがこの解決策をサポートしていますが、モバイルデバイスはほとんどサポートしていません。

この解決策を選択しないもう1つの理由は、標準的な解決策よりも大幅に遅くなる可能性があります。

この解決策は解像度に制限があります。 `near` をさらに小さくしたり `far` をさらに大きくしたりすると最終的に同じ問題にぶつかります。

`near` と `far` の設定は、常にユースケースに合った値を選択して下さい。 `near` はカメラからできるだけ離れた所に置き、オブジェクトが消えないようにしましょう。 `far` はカメラからできるだけ近い所に置き、オブジェクトが消えないようにしましょう。

もしまつげを見れるぐらい誰かの顔をクローズアップし、背景には50キロ離れた山までの道のりを見る巨大なシーンを描画したい場合、他の創造的な解決策を見つける必要があるでしょう。 この解決策は後にしましょう。 とりあえず自分のニーズに合わせて `near` と `far` は適切な値を選択しましょう。

2番目に一般的なカメラは `OrthographicCamera(平行投影カメラ)` です。 錐台を指定するのではなく、`left`、`right`、`top`、`bottom`、`near`、`far` の設定でボックスを指定します。 ボックスを投影しているので遠近感はありません。

上記のview1とview2のサンプルを変更し、最初のビューで `OrthographicCamera` を使うようにしましょう。

最初に `OrthographicCamera` を設定します。

    const left = -1;
    const right = 1;
    const top = 1;
    const bottom = -1;
    const near = 5;
    const far = 50;
    const camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far);
    camera.zoom = 0.2;

`left` と `bottom` を-1、`right` と `top` を1にしました。 これで箱の幅が2、高さが2になりますが、描画している矩形のアスペクト比で `left` と `top` を調整します。 `zoom` プロパティでカメラで実際に表示される値を簡単に調整できます。

GUIに `zoom` の設定を追加してみましょう。

    const gui = new GUI();
    +gui.add(camera, 'zoom', 0.01, 1, 0.01).listen();

`listen` 呼び出しはlil-guiに変更を監視するようにします。 これは `OrbitControls` がズームも制御できるからです。 例えばマウスのスクロールホイールは `OrbitControls` でズームします。

最後に左側をレンダリングする部分を変更して `OrthographicCamera` を更新します。

    {
      const aspect = setScissorForElement(view1Elem);

      // update the camera for this aspect
    -  camera.aspect = aspect;
    +  camera.left   = -aspect;
    +  camera.right  =  aspect;
      camera.updateProjectionMatrix();
      cameraHelper.update();

      // don't draw the camera helper in the original view
      cameraHelper.visible = false;

      scene.background.set(0x000000);
      renderer.render(scene, camera);
    }

これで `OrthographicCamera` が動作しているのが見れるようになりました。

ここをクリックして別のウィンドウで開きます

three.jsで2次元のものを描画する場合には、`OrthographicCamera` が最もよく使われます。 カメラの表示台数を決める必要があります。 例えば、1ピクセルのキャンバスをカメラの1単位と一致させたい場合、次のような事ができます。

原点を中心に置き、1ピクセル = three.jsの1単位とするには次のようにします。

    camera.left = -canvas.width / 2;
    camera.right = canvas.width / 2;
    camera.top = canvas.height / 2;
    camera.bottom = -canvas.height / 2;
    camera.near = -1;
    camera.far = 1;
    camera.zoom = 1;

原点を2Dキャンバスのように左上に配置したい場合は、次のようにします。

    camera.left = 0;
    camera.right = canvas.width;
    camera.top = 0;
    camera.bottom = canvas.height;
    camera.near = -1;
    camera.far = 1;
    camera.zoom = 1;

この場合、左上の角は2Dキャンバスのように0, 0になります。

やってみましょう！まずはカメラの設定をします。

    const left = 0;
    const right = 300;  // default canvas size
    const top = 0;
    const bottom = 150;  // default canvas size
    const near = -1;
    const far = 1;
    const camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far);
    camera.zoom = 1;

続いて、6枚のテクスチャをロードし、6枚の平面を作ってみましょう。 各平面は `THREE.Object3D` を親にし、平面の中心を左上にして簡単にオフセットできるようにします。

    const loader = new THREE.TextureLoader();
    const textures = [
      loader.load('resources/images/flower-1.jpg'),
      loader.load('resources/images/flower-2.jpg'),
      loader.load('resources/images/flower-3.jpg'),
      loader.load('resources/images/flower-4.jpg'),
      loader.load('resources/images/flower-5.jpg'),
      loader.load('resources/images/flower-6.jpg'),
    ];
    const planeSize = 256;
    const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
    const planes = textures.map((texture) => {
      const planePivot = new THREE.Object3D();
      scene.add(planePivot);
      texture.magFilter = THREE.NearestFilter;
      const planeMat = new THREE.MeshBasicMaterial({
        map: texture,
        side: THREE.DoubleSide,
      });
      const mesh = new THREE.Mesh(planeGeo, planeMat);
      planePivot.add(mesh);
      // move plane so top left corner is origin
      mesh.position.set(planeSize / 2, planeSize / 2, 0);
      return planePivot;
    });

キャンバスサイズの変更時、カメラを更新する必要があります。

    function render() {

      if (resizeRendererToDisplaySize(renderer)) {
        camera.right = canvas.width;
        camera.bottom = canvas.height;
        camera.updateProjectionMatrix();
      }

      ...

`planes` は `THREE.Mesh` の配列であり、各平面に1つずつあります。 これらを時間に応じて移動させてみましょう。

    function render(time) {
      time *= 0.001;  // convert to seconds;

      ...

      const distAcross = Math.max(20, canvas.width - planeSize);
      const distDown = Math.max(20, canvas.height - planeSize);

      // total distance to move across and back
      const xRange = distAcross * 2;
      const yRange = distDown * 2;
      const speed = 180;

      planes.forEach((plane, ndx) => {
        // compute a unique time for each plane
        const t = time * speed + ndx * 300;

        // get a value between 0 and range
        const xt = t % xRange;
        const yt = t % yRange;

        // set our position going forward if 0 to half of range
        // and backward if half of range to range
        const x = xt < distAcross ? xt : xRange - xt;
        const y = yt < distDown   ? yt : yRange - yt;

        plane.position.set(x, y, 0);
      });

      renderer.render(scene, camera);

2Dキャンバスのようにピクセル計算を使い、画像がキャンバスの縁からピクセルのように跳ね返っているのが分かります。

ここをクリックして別のウィンドウで開きます

`OrthographicCamera` のもう1つの一般的な用途は3Dモデリングツールやゲームエンジンで、上、下、左、右、正面、背面のビューを描画する場合です。

上記のスクリーンショットでは右上のビューが透視投影図、左上のビューが平行投影図です。

それがカメラの基本です。 カメラを動かすための一般的な方法は別の記事で紹介します。 とりあえず影についてのページに移りましょう。