0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

初代Game Boy『X』はどうやって3Dを実現したのか (8bitで動く3Dグラフィックスの基礎)

Posted at

この記事について

皆さんは、初代Game Boy(DMG-01)向けに発売された名作『X』というゲームを御存じでしょうか。

X (エックス)

1989年のGame Boyは4.19MHzのCPUと4色表示という限界的な仕様でした。
しかし1992年、『X』はその制約の中でリアルタイム3D空間を描いた初の携帯機ゲームとなり、後の『スターフォックス』開発へとつながります。

本記事では

  1. 「3DGFXとは何か?」を理論から整理
  2. 『X』がどのように3Dを成立させたかを技術的に分析
  3. 実際にワイヤーフレーム+タイルベース描画で再現

することで、8bit CPUとタイルベースの描画エンジンしか持たないGB上で、どうやって「3D」を動かせたのかを考察し、3Dグラフィクスの根本構造を振り返って実際にワイヤーフレーム+タイル描画で再現してみます。


3DGFXとは何か

どんなグラフィックも、最終的には平面のディスプレイ上に描画される絵となります。そこで、まずは2Dグラフィックと3Dグラフィックの処理の違いをまず確認したいと思います。

種類 主な処理 概要
2DGFX スプライト・タイル描画 画面上の座標だけ扱う
3DGFX 頂点変換・射影 3D座標を2Dへ射影する計算

つまり、3Dグラフィクスとは「仮想的に構築した3次元空間中の頂点を平面上に投影する計算」を行うグラフィック処理と考えることができます。

現代のGPUではこれはGPUに備わるシェーダ(頂点シェーダ)が担当しますが、Gameboyの『X』ではCPUがその仕事を手計算で行っていたことになります。

ワイヤーフレーム描画 = 頂点シェーダの処理

では次に、「3次元空間中の頂点を平面上に投影する計算」をもう少し詳しく見てみたいと思います。これらは行列演算で構築されます。3Dグラフィックスの基本的なことを理解されている方は読み飛ばしていただいても問題ありません。

描画する題材として、シンプルな立方体を定義したいと考えます。まずはこの立方体のオブジェクトの定義を行う必要があります。

立方体には頂点が8点存在します。これらの点の3次元空間上の点を定めることで、立方体を定義することができます。

// 立方体の頂点群
const verts = [
  [-1,-1,-1],[ 1,-1,-1],[ 1, 1,-1],[-1, 1,-1], // back (z=-1)
  [-1,-1, 1],[ 1,-1, 1],[ 1, 1, 1],[-1, 1, 1], // front (z=+1)
];

ここで、立方体は、立方体の中心を原点とした座標系で行います。この座標系をローカル座標系といいます。

今回は立方体を一つしか定義していませんが、複数のオブジェクトを定義する場合は、それぞれのオブジェクトでローカル座標系を持ち、その座標系上でオブジェクトの形状を定義します。

右手系、左手系

ちなみに、座標系には奥行きの方向(z方向)の正の向きで、右手系、左手系が存在します。本稿は 右手系(RHS)・OpenGL 準拠で記載しています。
ビュー空間でカメラは原点、前方は (-Z)、上は (+Y)、右は (+X)となります。

座標系への変換

さて、立方体を定義しましたが、このままではオブジェクトがローカル座標系で定義されているため、ワールド上で統一的な描画ができません。描画するためには、それぞれのオブジェクトをワールド座標系に配置する必要があります。

さらに視点(カメラ)から見た場合に、どの位置に配置されるのかの変換も必要となります。

また、その結果得られた座標が、最終的にディスプレイ上のどのピクセルに落ちるを射影する変換も必要となります。これらの変換をすべて経て、めでたくディスプレイ上に3Dオブジェクトが描画されることになります。

これらの変換パイプラインは下記のようになります。

  1. ローカル(モデル)座標系:メッシュ固有の原点・単位
  2. ワールド座標系:シーンの統一座標(複数モデルの位置合わせ)
  3. ビュー(アイ)座標系:カメラ視点の座標(カメラ原点・視線方向)
  4. クリップ空間:射影行列適用後の同次座標
  5. NDC(正規化デバイス座標):同次除算(/w)後の座標)
  6. ビューポート:NDC座標をピクセル座標(スクリーン座標)に射影

これらの処理はそれぞれ、各工程で定義される変換行列を順に乗算していくことで実施できます。

変換行列の定義

ここまでで、3D描画は「頂点を行列で変換し、平面に射影する」計算であることが分かりました。
以降では、この計算を実際に行う行列(M, V, P)を定義します。

まずは、(1)~(4)までの演算ですが、これは下記の行列を定義して乗算することで実現できます。

M(Model行列) : 物体の回転・拡縮
V(View行列) : カメラ座標変換
P(Projection行列) : 透視 or 平行射影

\mathbf{p}_{clip} = 
\begin{bmatrix}x_{clip}\\y_{clip}\\z_{clip}\\w\end{bmatrix} =
\underbrace{P}_{\text{Projection}}
\underbrace{V}_{\text{View}}
\underbrace{M}_{\text{Model}}
\begin{bmatrix}x\\y\\z\\1\end{bmatrix}

Projection(透視投影)行列

P_{(RH)} =
\begin{bmatrix}
\frac{1}{aspect \cdot \tan(fovY/2)} & 0 & 0 & 0 \\
0 & \frac{1}{\tan(fovY/2)} & 0 & 0 \\
0 & 0 & -\frac{f+n}{f-n} & -\frac{2fn}{f-n} \\
0 & 0 & -1 & 0
\end{bmatrix}

View行列(カメラ行列 / LookAt行列)

カメラの情報(Eye, Look, Up)から構築します。

  1. 前方向ベクトル(カメラの視線方向)
    $$
    \mathbf{z'} = \frac{Eye - Look}{||Eye - Look||}
    $$

  2. 右方向ベクトル
    $$
    \mathbf{x'} = \frac{Up \times z'}{||Up \times z'||}
    $$

  3. 上方向ベクトル
    $$
    \mathbf{y'} = z' \times x'
    $$

  4. ビュー行列

    V =
    \begin{bmatrix}
    x'_x & x'_y & x'_z & -\mathbf{x'} \cdot Eye \\
    y'_x & y'_y & y'_z & -\mathbf{y'} \cdot Eye \\
    z'_x & z'_y & z'_z & -\mathbf{z'} \cdot Eye \\
    0 & 0 & 0 & 1
    \end{bmatrix}
    

NDC への同次除算

さらに(5)については、下記の演算で達成できます。
PVMを乗算した後の透視除算(x, y, z を w で割る)処理です。

(x_\text{ndc},y_\text{ndc},z_\text{ndc})=\left(\frac{x_\text{clip}}{w_\text{clip}},\frac{y_\text{clip}}{w_\text{clip}},\frac{z_\text{clip}}{w_\text{clip}}\right)

ビューポート(画面幅 (W), 高さ (H))への写像

最期に(6)の演算を行うことで、スクリーンの2D座標系の最終的な座標を得ることができます。

x_\text{screen}=\left(\frac{x_\text{ndc}}{2}+\frac{1}{2}\right)W,\quad
y_\text{screen}=\left(-\frac{y_\text{ndc}}{2}+\frac{1}{2}\right)H

これはまさに現代GPUの「頂点シェーダ」で行う演算(+その後のラスタライズ処理)の基礎であり本質です。つまり、ワイヤーフレーム描画とは頂点シェーダの最小計算を表しています。

『X』がどうやって3Dを動かしたかを推測する

3Dグラフィック処理で必要となる計算を見てきました。GBだろうと、現代のGPUであろうと、これらの計算が行われることには変わりありません。そのため、この計算さえできれば3Dグラフィックが描画できると思われます。

しかし、GBでは非力なCPUしか搭載されていません。そのため、なるべく演算を簡略化する数々のテクニックが施されていたのではないかと推測されます。

ここでは、開発者インタビューなどの記事内容を参考に、実際にXで使われたであろう、演算テクニックの一部を推測します。

演算高速化

  • 固定小数点(8.8形式)化
  • sin / cos / 1/z は LUT(256段など)で事前計算

投影とスクリーン変換

  • 透視射影 x = x * (f / z) の近似化による除算回避(LUT+DDAなど)
  • near/farのクリップを単純化(z閾値判定など)

描画/VRAM転送

  • 線分をBresenham法でピクセル化
  • ピクセルを8×8 タイル単位で更新
  • 前フレームで書き換えたタイルだけVRAMへ転送(差分更新)
  • 同じ形状のタイル(空白・水平線など)をVRAMにキャッシュし再転送を抑止

疑似SIMD

Z80にSIMD命令がないため、ハード特性からの推測として、『X』では次のような擬似的な並列化処理が行われていた可能性はあります。

手法 内容
ニブル並列 1バイトを上位/下位4bitに分け、2レーンで処理
ビットプレーン演算 1バイト = 8ピクセル(2bpp)として AND/OR/XOR 同時更新

バックフェースカリング(背面消去)

3D空間ではカメラから見えない面(背面)を描く必要はありません。ビュー空間で法線ベクトルnとカメラ方向−cの内積をとり、

\text{front-facing} \iff \mathbf{n} \cdot (-\mathbf{c}) > 0

のときのみ描画します。Game Boy のような低演算環境では、内積の代わりに 法線のZ成分(nz > 0)だけで簡易判定する場合もありました。(この場合は、視点が −Z を向く前提でのヒューリスティックなので、モデルが大きく回転する場合は誤判定が生じる場合があります。)

このテクニックを利用するため、立方体の定義に、下記のように法線ベクトルの定義を追加する必要があります。

// 頂点(再掲:立方体)
const verts = [
  [-1,-1,-1],[ 1,-1,-1],[ 1, 1,-1],[-1, 1,-1],
  [-1,-1, 1],[ 1,-1, 1],[ 1, 1, 1],[-1, 1, 1],
];

// 各面の頂点インデックス(CCW)
const faces = [
  [0,1,2,3], // back
  [4,5,6,7], // front
  [0,4,5,1], // bottom
  [3,7,6,2], // top
  [0,3,7,4], // left
  [1,2,6,5], // right
];

// 法線ベクトル
const normals = [
  [ 0,  0, -1], // back
  [ 0,  0,  1], // front
  [ 0, -1,  0], // bottom
  [ 0,  1,  0], // top
  [-1,  0,  0], // left
  [ 1,  0,  0], // right
];

この定義から、dot(n, -c) > 0の場合だけ描画すればCPUでも軽量にカリングが可能です。

再現実験:CPUによる3D描画

以下は、JavaScriptでGBの3D描画構造を再現した実験コードです。
CPU側でワイヤーフレームを計算し、PPU側(Canvas)に渡して差分更新するタイルを作成する処理を模擬しています。

実験コード

See the Pen 3D Wireframe (GB) by Moru (@Y-Mori) on CodePen.

このデモでは下記を再現しています。

  • 固定小数点演算
  • LUT参照
  • near/farクリップ + カリング
  • Bresenham法でタイルピクセルを打ち込み
  • 差分タイルだけ再転送

要点

GB風の「タイル単位」「差分だけ更新」描画の再現

  • 画面を 8×8 のタイルで管理(20×18 タイル)
  • 1タイル= 2bpp(0..3)のピクセル64個
  • 前フレームと今フレームの集合を突き合わせて差分のみ再描画
  • パレットと 2bpp の表現(GB風カラーの4段階・2bpp)
const palette = [
  [0xE0,0xF8,0xD0,255],
  [0x88,0xC0,0x70,255],
  [0x34,0x68,0x56,255],
  [0x08,0x18,0x20,255],
];
let LINE_COL = 3;

const tiles = [];
const tileCache = new Map();
const tilemap = new Uint16Array(MAP_W * MAP_H);

const emptyTile = new Uint8Array(64);
const EMPTY_KEY = emptyTile.join(',');
tileCache.set(EMPTY_KEY, 0);
tiles.push(emptyTile);
for (let i=0;i<tilemap.length;i++) tilemap[i] = 0;

const off = document.createElement('canvas');
off.width = SCR_W; off.height = SCR_H;
const octx = off.getContext('2d', { willReadFrequently:true });
let img = octx.createImageData(SCR_W, SCR_H);

線のラスタライズ(Bresenham)

function drawLine(x0,y0,x1,y1){
  x0|=0; y0|=0; x1|=0; y1|=0;
  let dx=Math.abs(x1-x0), sx=x0<x1?1:-1;
  let dy=-Math.abs(y1-y0), sy=y0<y1?1:-1;
  let err=dx+dy;
  while(true){
    plotPixel(x0,y0);
    if(x0===x1 && y0===y1) break;
    const e2=2*err;
    if(e2>=dy){ err+=dy; x0+=sx; }
    if(e2<=dx){ err+=dx; y0+=sy; }
  }
}

LUT

// sin/cos LUT(0..2π を 分割)

const TRIG_N = 256;
const SIN_LUT = new Int32Array(TRIG_N);
const COS_LUT = new Int32Array(TRIG_N);
for(let i=0;i<TRIG_N;i++){
  const a = i * 2*Math.PI / TRIG_N;
  SIN_LUT[i] = toFx(Math.sin(a));
  COS_LUT[i] = toFx(Math.cos(a));
}

const TRIG_SCALE = TRIG_N / (2*Math.PI);
function sinFx(radFx){ // radFx: 固定小数点の角度(ラジアン)
  let r = fromFx(radFx); // 角度は LUT 添字化だけfloatでOK(実値の誤差のみ)
  r = r % (2*Math.PI); if(r<0) r += 2*Math.PI;
  return SIN_LUT[(r*TRIG_SCALE)|0];
}

function cosFx(radFx){
  let r = fromFx(radFx);
  r = r % (2*Math.PI); if(r<0) r += 2*Math.PI;
  return COS_LUT[(r*TRIG_SCALE)|0];
}

// cot(fov/2) LUT(度 1..179)
const COT_LUT = new Int32Array(180);
for(let d=1; d<180; d++){
  COT_LUT[d] = toFx(1/Math.tan((d*Math.PI/180)*0.5));
}
function cotHalfFovDegFx(deg){
  const d = Math.max(1, Math.min(179, deg|0));
  return COT_LUT[d];
}

// 1/w LUT
let RECIP_LUT = null, RECIP_N = 256, RECIP_NEAR_FX=toFx(0.1), RECIP_FAR_FX=toFx(20), RECIP_STEP_FX=0;
function rebuildRecipLUTFx(nearFx, farFx){
  RECIP_NEAR_FX = Math.max(1, nearFx);
  RECIP_FAR_FX  = Math.max(RECIP_NEAR_FX+1, farFx);
  RECIP_STEP_FX = fxDiv(fxSub(RECIP_FAR_FX, RECIP_NEAR_FX), toFx(RECIP_N-1));
  RECIP_LUT = new Int32Array(RECIP_N);
  for(let i=0;i<RECIP_N;i++){
    const wFx = fxAdd(RECIP_NEAR_FX, fxMul(toFx(i), RECIP_STEP_FX));
    RECIP_LUT[i] = fxDiv(toFx(1), wFx); // 1/w を固定小数点で
  }
}

function invW_LUTFx(wFx){
  if(wFx <= RECIP_NEAR_FX) return fxDiv(toFx(1), RECIP_NEAR_FX);
  if(wFx >= RECIP_FAR_FX)  return fxDiv(toFx(1), RECIP_FAR_FX);
  const num = fxSub(wFx, RECIP_NEAR_FX);
  const t   = fromFx(fxDiv(num, RECIP_STEP_FX));
  const i   = t|0, frac = t - i;
  const a = RECIP_LUT[i], b = RECIP_LUT[i+1];
  return toFx(fromFx(a) + (fromFx(b)-fromFx(a))*frac);
}

背面カリング

function computeCulledFacesFx(eyeVerts, VM){
  const nViews = normals.map(n => transform3Fx(VM, n));
  const culled = new Set();
  for (let fi = 0; fi < faces.length; fi++) {
    // front-facing ≒ n_view.z > 0
    if (!(nViews[fi][2] > 0)) culled.add(fi);
  }
  return culled;
}

3Dパイプライン対応表(GB vs GPU)

概念 現代GPU Game Boy『X』実装(推測)
頂点変換 頂点シェーダ(MVP行列) Z80固定小数点計算
射影 GPU透視変換 LUT+DDA近似
ラスタライズ フラグメントシェーダ Bresenham法
フレームバッファ VRAM タイルバッファ
描画更新 全画面更新 差分タイルの更新のみ

まとめ

「ワイヤーフレームを描く」ことは、単なる古典的デモではなく、3D描画パイプラインの最小構成を体験することでもあります。

  • 3Dグラフィックスは数学であり、GPUはその高速化装置
  • 高速化ができなくても、演算絞り込みや、転送最適化で限界ハードでも3Dは実現可能

あらためてGame Boy『X』で採用された技術は素晴らしいと思いました。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?