Email: Takayama Fumihiko <tekezo@pqrs.org>

GBA で学ぶ古典的プログラミング (スプライトダブラ)

始めに

GBA とは?

任天堂から発売されている携帯ゲームマシン GAMEBOY ADVANCE。 簡単なスペックは以下の通りです。
CPU ARM7 CPU (32bit RISC)
メモリ 外部メモリ 256KB
内部メモリ 32KB
VRAM 96KB
画面解像度 240x160
スプライト 最大 128 個
サイズ: 8x8 〜 64x64

ファミコンからの流れを汲むガチガチの 2D マシンです。 浮動小数点演算ユニットもありませんし、ポリゴン用のハードウェアも載っていませんが、 手頃な CPU パワーとスプライトがあるので 2D のプログラムはサクサク動きます。

スプライトダブラとは?

ラスタ割り込みでスプライトの情報を書き換えることで、 スプライトを限界を越えて表示する技術です。 昔のハードウェア、例えば X68000 や MSX などが現役の時代に限られたスプライトを有効利用するために使われていました。

この技術を GBA で用いると、例えば 128 個以上のスプライトを一度に表示することが可能になります。

単純な例ですと、以下のようにスプライト 0 を画面の左上に表示していたとします。
sprite1.png

その際に、スプライト 0 が表示された後のラスタ割り込みでスプライト 0 の位置を変更すると、 スプライト 0 を画面に 2 つ表示することが出来ます。
sprite2.png

これをスプライト 0 〜 スプライト 127 までに対して行えば、256 個のスプライトを表示することが出来ます。

スプライトダブラの実践

始めに

GBA ではスプライトの情報を以下のような形で持っています。
typedef struct {
        u16 attr0; // Y 座標などのデータ
        u16 attr1; // X 座標などのデータ
        u16 attr2; // タイル情報、パレット情報などのデータ
        u16 dummy;
} OBJATTR;
この OBJATTR の配列 (128 個分) が OAM と呼ばれるメモリ空間に格納されていますので、 スプライトダブラではラスタ割り込みの処理で OAM を書き換えることになります。

方針

スプライトダブラを実装するにあたっては、ラスタ割り込みの処理を工夫する必要があります。 というのも、割り込み処理はあまり時間のかかる処理を行うことが出来ません。 ループをまわしてスプライト情報を書き換えることは現実的とは言い難いものがあります。

そこで、事前に書き換え後のスプライト情報を用意しておいて、 ラスタ割り込みではそれらのデータを DMA 転送させる方法を取ります。

基本的な構造

スプライトダブラで最大 512 個のスプライトを表示しようとする場合、 512 個分の OBJATTR データをあらかじめ用意しておき、 ラスタ割り込みの際に少しづつデータをずらして上書きコピーしていけば 512 個のスプライトを表示することが出来ます。
フレーム開始時 oam01.png
ラスタ割り込み oam02.png
このようにコピーをしていくためには、 あらかじめ用意する 512 個の OBJATTR は y 軸に沿ってソートされている必要があります。 そのため、スプライトダブラの主な処理として、以下の 2 つを用意します。
  1. ソートされた 512 個の OBJATTR を用意する。
  2. ラスタ割り込みで、それらを上書きコピーする。
オマケ: 単純な構造のダブラ

BulletGBA から見る実装方式

BulletGBA のソースコードから見るスプライトダブラの実装

BulletGBA のソースコードのからスプライトダブラの実装を見てみます。 前提として、 BulletGBA では y 軸を 4 ライン毎に区切って、それらを一塊のブロックとして扱います。

これは、 OAM への DMA 転送が最大 2 ライン分の時間がかかると考え、 最高でも 4 ライン毎でしか DMA 転送をスタートしないことに起因しています。 ラスタ割り込みが最大限発生する場合には VCOUNT が 0, 4, 8, 12, ..., 152 の場合のラスタ割り込みで DMA 転送をスタートさせます。

その場合、一度の DMA 転送で扱うスプライトは最大 4 ライン分、つまり一塊のブロック分になります。

ソートされた 512 個の OBJATTR の用意

class SpriteDoubler {
  static const u32 LINEBLOCK = 4;
  static const u32 MAXITEM = 512;
  static const u32 NUM_BLOCK = 160 / LINEBLOCK;

  class CompiledObjattr {
    OBJATTR sortedOBJATTR[MAXITEM]; // 512 個分のソート済み OBJATTR

    u32 itemNumInBlock[NUM_BLOCK]; // 各ブロックに含まれるスプライトの数

    OBJATTR *objattrStartPosInBlock[NUM_BLOCK]; // sortedOBJATTR に対するポインタ。 各ブロックに対応するコピー先エリア。
  };
};
上で見たように、 BulletGBA では 4 ラインを一塊として見るので、 OBJATTR のソートも正確に y 軸に沿っていなくても 4 ライン分は同一視してかまわないことになります。

そこで、まずは 4 ライン毎に区切られた各ブロックにそれぞれ幾つのスプライトが属するかをカウントします。 これらの個数が itemNumInBlock に格納されます。 例えば itemNumInBlock[0] は 0 〜 3 ラインに含まれるスプライトの数、 itemNumInBlock[1] は 4 〜 7 ラインに含まれるスプライトの数になります。

次に、 512 個分の OBJATTR に対して、各ブロックに対応するコピー先エリアを設定します。 下記の makeObjAttrStartPosInBlock 関数で設定を行います。 各ブロックに所属するスプライトについて *objattrStartPosInBlock に attribute を設定すれば良いことになります。
    void makeObjAttrStartPosInBlock() {
      objattrStartPosInBlock[0] = sortedOBJATTR;
      for (u32 i = 0; i < NUM_BLOCK - 1; ++i) {
        objattrStartPosInBlock[i + 1] = objattrStartPosInBlock[i] + itemNumInBlock[i];
      }
    }
このように事前準備を行った上でソート済み OBJATTR を作成します。 大雑把に下記のような処理で sortedOBJATTR を作成していきます。
    void registObjAttr(u32 attr0, u32 attr1, u32 attr2) {
      u32 idx = attr0 / LINEBLOCK;

      OBJATTR *p = objattrStartPosInBlock[idx];

      p->attr0 = attr0;
      p->attr1 = attr1;
      p->attr2 = attr2;

      ++(objattrStartPosInBlock[idx]);
    }
こうすることで、ソート済み OBJATTR が sortedOBJATTR 上に構築されました。 実際にこれらの処理を呼び出す処理は、 GameEngine::compileBullet などで行われています。
void
GameEngine::compileBullet()
{
  SpriteDoubler::CompiledObjattr *p = SpriteDoubler::getIncurrentCompiledObjattr();
  p->initialize();

  BulletInfo *bi;
  // ------------------------------------------------------------
  bi = ListBullets::getFirstItem();
  for (;;) {
    if (bi == NULL) {
      break;
    }
    p->registItemNumInBlock(bi->posy);

    bi = ListBullets::iterator(bi);
  }

  // ------------------------------------------------------------
  p->normalizeItemNumInBlock();
  p->makeObjAttrStartPosInBlock();

  // ------------------------------------------------------------
  bi = ListBullets::getFirstItem();
  for (;;) {
    if (bi == NULL) {
      break;
    }

    p->registObjAttr(bi->posy, bi->posx, OBJ_PALETTE(0) | bi->type);

    bi = ListBullets::iterator(bi);
  }
}

ラスタ割り込みによる OAM の DMA 転送

まずは VBlank 割り込みで OAM の初期設定を行います。 その後に、ラスタ割り込みを行うべき VCOUNT の値を設定します。

注意点としては、 n 番目のブロックを転送するには、 4 ライン前の VCOUNT、つまり (n - 1) * 4 ライン目で割り込みを発生させる必要があります。 このタイミングで転送をスタートしておけば、実際に n ブロック目が描写されるタイミング (n * 4 ライン目) には OAM への DMA 転送が終了している計算になります。
void
SpriteDoubler::irq_vblank()
{
  p->srcStart = p->sortedOBJATTR;
  p->dstStart = OAM + DOUBLER_SPRITE_START;

  u32 size = p->firstCopySize;
  DMA1COPY(p->srcStart, p->dstStart, DMA32 | DMA_IMMEDIATE | (size * sizeof(OBJATTR) / 4));

  p->nextIdx = p->itemContainIdx;
  u32 vcount = (*(p->nextIdx) - 1) * LINEBLOCK;
  REG_DISPSTAT = (REG_DISPSTAT & 0xff) | VCOUNT(vcount);
}
あとは VCOUNT での割り込みで対応しているブロックの OBJATTR を転送していくだけです。 割り込みの最後で次の割り込みの設定を行うことで次々と VCOUNT 割り込みを発生させています。
void
SpriteDoubler::irq_vcount()
{
  u32 num = p->itemNumInBlock[*(p->nextIdx)];
  DMA1COPY(p->srcStart, p->dstStart, DMA32 | DMA_IMMEDIATE | (num * sizeof(OBJATTR) / 4));
  p->srcStart += num;
  p->dstStart += num;

  ++(p->nextIdx);
  u32 vcount = (*(p->nextIdx) - 1) * LINEBLOCK;
  REG_DISPSTAT = (REG_DISPSTAT & 0xff) | VCOUNT(vcount);
}
実際にはコピー先が OAM の最後に達した際には、 wrap 処理を行うなど細かい制御が必要になりますが、 基本的には上記のようなロジックでラスタ割り込み処理を行います。

オマケ

単純な構造のダブラ

上記の構造 だとコピー先の OAM アドレスが毎回違っていたり、 あらかじめ用意する 512 個分のスプライトメモリは隙間なく並べておかないといけないなど、 ロジックが複雑になりがちです。 そこで、下記のような構造を取ると簡単にダブラを実装できます。

この場合、 40 個の OBJATTR からなるブロックをラスタ割り込みの毎にコピーしていく形になるので、 コピー先の OAM アドレスは 3 種類で固定されていますし、 あらかじめ用意するスプライトメモリの構造も単純です。

ただし、この構造の場合、一度に扱えるスプライト数が 40 個に制限されてしまうので、 スプライトの表示数を限界まで高めたい場合には 上記の構造 を取る必要があります。
フレーム開始時 oam-another01.png
ラスタ割り込み(1) oam-another02.png
ラスタ割り込み(2) oam-another03.png

単純な構造のダブラとの比較

左が単純な構造のダブラ、右が複雑のダブラです。
(下のバーにマウスカーソルをあわせると再生できます)。


Comments for This Page.
Date: 2007-03-21 00:00 (JST)