DDDDbox Tech Blog

建築設計クラウドサービス『DDDDbox』のテックブログ

3Dプログラミングにおける座標変換の基礎概念

こんにちは、DDDDbox事業部で日々3D開発を行っている鈴木です。 開発中によく遭遇する問題として

  • オブジェクトの表示が想定位置とずれている
  • カメラの位置を変えたら想定と違う見え方になった

といったものがあります。

原因を調べていくと、これらの問題の多くは座標変換の理解不足が主な原因なことが多くありました。

座標変換は3Dプログラミングの基礎中の基礎なのですが、ここの理解があやふやだと、AIに言われるがままの修正に頼ってしまいがちになると感じています。

今回は、3Dオブジェクトが最終的に画面に表示されるまでの「座標系と座標変換」の流れを、実際のコード例とともに整理してみました。

座標系の種類

3D空間における座標の表現方法は以下の通りです。

  • ローカル座標 - キューブ自身から見た位置
    • 3D座標
  • ワールド座標 - シーン全体から見た位置
    • 3D座標
  • ビュー座標 - カメラから見た位置
    • 3D座標
  • クリップ座標 - カメラから見える範囲内にあるかどうかを判定するための座標
    • 4D座標
  • 正規化デバイス座標(NDC) - クリップ座標を「-1〜1」の範囲に正規化した座標系
    • 2D座標
  • スクリーン座標 - 最終的な画面上の位置
    • 2D座標

これらはすべて同じオブジェクトの「位置」を表していますが、座標系や次元が異なります。これらの座標系の間の変換を座標変換といい、3Dプログラミングでは頻繁に利用されます。

ローカル座標系 → ワールド座標系(モデル変換)

ローカル座標とワールド座標

ワールド座標系とは

ワールド座標系は、シーン全体の基準となる座標系です。例えば

  • ワールド座標の原点(0,0,0)を中心として
  • ワールド座標(10,0,0)にキューブ1を配置
  • ワールド座標(0,10,0)にキューブ2を配置

といった具合に、シーン内のすべてのオブジェクトの位置関係を統一的に管理します。

ローカル座標系(モデル座標系)とは

ローカル座標系は、オブジェクト(モデル)自身を基準とした座標系です。例えば

  • あるオブジェクトがワールド座標(5,0,0)、ローカル座標(2,0,0)の位置にある場合
  • このオブジェクトは実際にはワールド座標(7,0,0)の位置に表示される

といった具合に、ローカル座標はオブジェクト自身の原点を基準とした座標系です。

また、オブジェクト同士で座標の比較を行いたい場合、ローカル座標同士では行うことができません。この時は、ローカル座標をワールド座標に変換してから比較計算を行う必要があります。

さらに、ローカル座標からワールド座標への変換をモデル変換と呼びます。 数学的には行列(Matrix4)を掛けることで実現され、平行移動、回転、拡大縮小を行うことができます。

コード例

import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';

const LocalToWorldExample = () => {
  const ref = useRef<THREE.Mesh>(null);
  
  useFrame(() => {
    if (!ref.current) return;
    
    const localPos = ref.current.position;
    // meshの親がネストしていても、正確にワールド座標を取得
    const worldPos = ref.current.getWorldPosition(new THREE.Vector3());
    
    console.log(
      "local:",
      localPos.x, localPos.y, localPos.z,  // (2, 0, 0)
      "world:",
      worldPos.x, worldPos.y, worldPos.z   // (8, 0, 0)
    );
  });
  
  return (
    <group position={[5, 0, 0]}>
      <group position={[1, 0, 0]}>
        <mesh ref={ref} position={[2, 0, 0]}>
          <boxGeometry args={[1, 1, 1]} />
          <meshStandardMaterial color="orange" />
        </mesh>
      </group>
    </group>
  );
};

この例では、meshの最終的な位置は 5 + 1 + 2 = 8となります。

three.jsでは getWorldPositionを使うことで、そのオブジェクトの親の構造に関係なく正確なワールド座標を取得できます。

ワールド座標系 → ビュー座標系(ビュー変換)

ビュー座標のイメージ

次は、ワールド座標をビュー座標へ変換する処理をみていきます。

ビュー座標系(カメラ座標系)とは

ビュー座標系は、カメラ位置を原点にした座標系です。そのためカメラ座標系とも呼ばれます。

ワールド座標系に存在するオブジェクトの座標を「カメラから見た座標」に変換(ビュー変換)するとビュー座標系になります。

ビュー座標系では、カメラの向き(lookAt)によって、軸の向きが決まります:

  • X軸 - 右手座標系でZ×Yから計算される「右」方向
  • Y軸 - カメラの「上」方向(通常はworld up vectorから計算)
  • Z軸 - カメラからlookAtのターゲットへの方向(視線方向、Z軸負方向)

ビュー変換は、数学的にはビュー行列を掛けることで実現され、この行列は、カメラの位置と向きに基づいて計算されます。

また、多くのグラフィックスライブラリでは lookAt() という名前の関数からビュー行列を作成することができます(three.jsは該当しない)。

コード例

import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import { PerspectiveCamera } from '@react-three/drei';
import * as THREE from 'three';

function ViewExample() {
  const boxRef = useRef();
  const cameraRef = useRef();

  useFrame(() => {
    if (!boxRef.current || !cameraRef.current) return;

    // ワールド座標(boxの位置)
    const worldPosition = boxRef.current.position;
    
    // カメラのビュー行列(ワールド座標からビュー座標への変換行列)
    const viewMatrix = cameraRef.current.matrixWorldInverse;
    
    // ワールド座標をビュー座標に変換
    const viewPosition = worldPosition.clone().applyMatrix4(viewMatrix);
    
    // 5,3,5
    console.log(
      "ワールド座標:",
      `(${worldPosition.x.toFixed(2)}, ${worldPosition.y.toFixed(2)}, ${worldPosition.z.toFixed(2)})`
    );
    // 0, -1.63, -1.15
    console.log(
      "ビュー座標:",
      `(${viewPosition.x.toFixed(2)}, ${viewPosition.y.toFixed(2)}, ${viewPosition.z.toFixed(2)})`
    );
    
    cameraRef.current.lookAt(0, 0, 0);
  });

  return (
    <>
      <PerspectiveCamera
        ref={cameraRef}
        position={[5, 5, 5]}
        fov={75}
        makeDefault
      />
      
      <mesh ref={boxRef} position={[5, 3, 5]}>
        <boxGeometry args={[1, 1, 1]} />
        <meshBasicMaterial color="orange" />
      </mesh>
    </>
  );
}

位置関係

実行例の解説:

  • カメラ位置:(5, 5, 5)から原点を見ている
  • ボックス位置:(5, 3, 5) はカメラの真下2単位離れた位置

ワールド座標では:ボックスはカメラの「真下」に位置(同じX, Z座標で、Y座標だけ2単位下)

しかし、ビュー座標系では:カメラが斜め下を向いているため、この「真下」の関係が複数の軸成分に分散されます

  • Y成分:-1.63(カメラから見た「下」方向)
  • Z成分:-1.15(カメラから見た「手前」方向)
  • X成分:-0.00(カメラから見た「左右」方向はほぼゼロ)

このように、カメラの位置だけでなく、カメラの向き(lookAt)も考慮されることがわかります。

ビュー座標系 → クリップ座標系(プロジェクション変換)

※ ここでは透視投影の場合を例に説明します。

ビュー空間の状態では、カメラ視点になっただけで、カメラに映らないものも座標系に含まれています。 プロジェクション変換では、これらのカメラに映らない部分をクリップして「カメラが見ることができる範囲」を定義できるクリップ座標系に変換されます。

この「見ることができる範囲」となる6面体を視錐台(frustum)と呼びます。

視錐台

クリップ座標は「カメラが見ることができる範囲」を数学的に表現した4次元空間上の座標(x,y,z,w)です。多くの場合、w=-zの値として設定されます(カメラの向きが-z軸方向のため)。

このwの存在により、「この範囲内なら画面に映るよ!」という判定(クリッピング)をすることができるようになります。

クリップ座標への変換(プロジェクション変換)では、以下の処理を行います。

  • X,Y方向の正規化
  • Z方向の正規化(near/far対応)
  • カメラからの距離情報の保持(w)

整理すると

  • 視錐台(Frustum): 「カメラの物理的な見える範囲」を決める
  • プロジェクション変換 : その範囲を後にNDCに正規化するためのクリップ座標を得る

という関係になります。

コード例

three.jsのAPIを使うと、複雑な概念をそれほど意識することなく、ビュー座標からクリップ座標を取得できます。

useFrame(() => {
  if (meshRef.current && cameraRef.current) {
    // ワールド座標
    const worldPos = meshRef.current.position;

    // カメラのビュー行列を取得してビュー座標に変換
    const viewMatrix = cameraRef.current.matrixWorldInverse;
    const viewPos = worldPos.clone().applyMatrix4(viewMatrix);

    // プロジェクション行列を適用してクリップ座標に変換
    const projectionMatrix = cameraRef.current.projectionMatrix;
    const clipPos = viewPos.clone().applyMatrix4(projectionMatrix);

    console.log(
      "ワールド座標:", worldPos.x.toFixed(2), worldPos.y.toFixed(2), worldPos.z.toFixed(2),
      "ビュー座標:", viewPos.x.toFixed(2), viewPos.y.toFixed(2), viewPos.z.toFixed(2),
      "クリップ座標:", clipPos.x.toFixed(2), clipPos.y.toFixed(2), clipPos.z.toFixed(2)
    );
  }
});

クリップ座標系 → 正規化デバイス座標(NDC)

クリップ座標は(x, y, z, w)の形になります。そして「wで各要素を割る」(= perspective divide-透視除算と呼ぶ)ことでNDCに変換されます。

// 例: ビュー座標にprojectionMatrixを適用してクリップ座標の取得
const clipPos = viewPos.clone().applyMatrix4(camera.projectionMatrix);

// 透視除算をして正規化デバイス座標(NDC)に変換
const ndcPos = clipPos.clone().divideScalar(clipPos.w);

console.log("NDC:", ndcPos.x, ndcPos.y, ndcPos.z); // -1〜1の範囲が映る

w除算によって、遠くの物体ほど小さく見える「透視効果(perspective)」が生まれます。

// 近くの物体(カメラから距離2)
const nearObject = [2, 2, z, 2]; // w = 2(距離に比例)
const nearNDC = [2/2, 2/2] = [1, 1]; // 大きく表示

// 遠くの物体(カメラから距離10)  
const farObject = [2, 2, z, 10]; // w = 10(距離に比例)
const farNDC = [2/10, 2/10] = [0.2, 0.2]; // 小さく表示

同じサイズの物体でも、遠くにあるほどw値が大きくなり、w除算の結果として小さく表示され、遠近感を表現することができます。

📦 標準視体積(Canonical View Volume)

標準視体積

標準視体積は、表示される物体が収まる範囲を定義する標準的な立方体(WebGLの場合は-1〜1)範囲の座標です。

この立方体は、以下のような軸を持っています。

  • X軸:-1(左端)〜 1(右端)
  • Y軸:-1(下端)〜 1(上端)
  • Z軸:-1(遠い)〜 1(近い)

この範囲外の座標値をとる物体は画面に表示されません。

備考: Frustum Culling

視錐台(view frustum)の外にあるオブジェクトを描画対象から除外する処理をFrustum Cullingといいます。

// 簡単なFrustum Cullingの例
function shouldRender(object) {
  const ndcPos = transformToNDC(object.position);
  
  // NDC範囲外(-1~1範囲外)なら描画スキップ
  if (!isVisible(ndcPos)) {
    return false; // 描画しない → パフォーマンス向上!
  }
  
  return true; // 描画する
}

これにより、見えないオブジェクトの処理をスキップして、パフォーマンス向上が期待できます。

スクリーン座標への変換(ビューポート変換)

ビューポート変換は、「正規化デバイス座標(NDC)」で表された座標(-1〜1)を、実際のディスプレイ上のピクセル座標(スクリーン空間)に変換する処理です。

スクリーン座標はピクセル単位の座標系で、WebGLの場合は左下が原点(0, 0)になります。

変換の計算式

標準的な変換式は以下の通りです(左下が原点の場合):

x_screen = (x_ndc + 1) * width / 2
y_screen = (y_ndc + 1) * height / 2

例: 画面サイズが1920×1080ピクセル、NDC座標が(0.5, -0.5)の場合

  • x_screen = (0.5 + 1) × 1920 / 2 = 1.5 × 960 = 1440
  • y_screen = (-0.5 + 1) × 1080 / 2 = 0.5 × 540 = 270

よって、スクリーン座標は(1440, 270)になります。

コード例

  // クリップ座標を正規化デバイス座標(NDC)に変換
  const ndcPos = new THREE.Vector3(
    clipPos.x / clipPos.w,
    clipPos.y / clipPos.w,
    clipPos.z / clipPos.w
  );

  // クリップ座標をスクリーン座標に変換(ビューポート変換)
  const screenPos = new THREE.Vector3(
    ((ndcPos.x + 1) * window.innerWidth) / 2,
    ((ndcPos.y + 1) * window.innerHeight) / 2,
    ndcPos.z
  );

座標変換パイプライン全体のまとめ

ここまでの座標変換の流れをまとめました。

  graph TD
      subgraph "3D座標系"
          A[ローカル座標]
          B[ワールド座標]
          C[ビュー座標]
      end

      subgraph "同次座標系"
          D[クリップ座標]
      end

      subgraph "2D座標系"
          E[正規化デバイス座標 NDC<br/>-1〜1の範囲に正規化]
          F[スクリーン座標<br/>2D: 最終的な画面上の位置]
      end

      A -->|モデル変換| B
      B -->|ビュー変換| C
      C -->|プロジェクション変換| D
      D -->|透視除算| E
      E -->|ビューポート変換| F

      style A fill:#e8f4f8
      style B fill:#e8f4f8
      style C fill:#e8f4f8
      style D fill:#fff4e6
      style E fill:#f0f8e8
      style F fill:#f0f8e8

各変換には明確な役割があり、それぞれが連携して3Dオブジェクトを2D画面に表示しています。

まとめ

今回は、3Dオブジェクトが画面に表示されるまでの座標変換を辿ってみました。

これらの座標変換を理解することで、「なぜオブジェクトが表示されないのか」「カメラの設定がどう影響するのか」といった疑問が解決できるようになります。

今回の説明では数学的説明を大幅に簡略化しています。さらに深く学びたい方は、行列計算やクォータニオンといった数学的な基礎、シェーダープログラミングなども調べてみてください。

DDDDboxでは、このような3Dグラフィックスの開発に多く取り組んでおり、力を貸していただけるエンジニアさんを大募集しております! 少しでもご興味をお持ちいただけましたら、カジュアルにお話するだけでも大丈夫ですのでお気軽にご連絡ください!

中途求人ページ: https://www.amd-lab.com/recruit-list/mid-career

カジュアル面談がエントリーフォームからできるようになりました。 採用種別を「カジュアル面談(オンライン)」にして必要事項を記載の上送信してください!

エントリーフォーム: https://www.amd-lab.com/entry