#author("2023-05-08T19:43:11+09:00","","")
> トップページ > GBA > ドキュメント
#freeze
#author("2023-06-15T19:21:09+09:00","","")
* スプライトダブラ [#e3a083e3]
スプライトダブラとは、VCOUNT割り込み(ラスタ割り込み)でスプライト情報を書き換える方法です。この技術を用いると128個以上のスプライトを一度に表示することが可能になります。昔のハードウェア、例えばX68000やMSXなどの時代でも使われていました。仮にスプライト0を画面の左上に表示したとします。赤線は現在のVCOUNTラインです。

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

ファミコンからの流れを汲むガチガチの 2D マシンです。 浮動小数点演算ユニットもありませんし、ポリゴン用のハードウェアも載っていませんが、 手頃な CPU パワーとスプライトがあるので 2D のプログラムはサクサク動きます。
■ スプライトダブラとは?
ラスタ割り込みでスプライトの情報を書き換えることで、 スプライトを限界を越えて表示する技術です。 昔のハードウェア、例えば X68000 や MSX などが現役の時代に限られたスプライトを有効利用するために使われていました。
スプライト0が表示された後、VCOUNT割り込みでスプライト0の位置を変更します。結果、画面に2つ表示することが出来ました。

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

単純な例ですと、以下のようにスプライト 0 を画面の左上に表示していたとします。
sprite1.png
スプライト0〜スプライト127までに対して行えば256個を表示することが出来るという寸法です。

その際に、スプライト 0 が表示された後のラスタ割り込みでスプライト 0 の位置を変更すると、 スプライト 0 を画面に 2 つ表示することが出来ます。
sprite2.png
** 始めに [#w8cb0b00]
GBAではスプライト情報を以下のような形で持っています。このOBJATTRの配列(128個分)がOAMと呼ばれるメモリ空間に格納されていますので、スプライトダブラではVCOUNT割り込み処理でOAMを書き換えることになります。

これをスプライト 0 〜 スプライト 127 までに対して行えば、256 個のスプライトを表示することが出来ます。
スプライトダブラの実践
■ 始めに
GBA ではスプライトの情報を以下のような形で持っています。

    typedef struct {
            u16 attr0; // Y 座標などのデータ
            u16 attr1; // X 座標などのデータ
            u16 attr2; // タイル情報、パレット情報などのデータ
            u16 dummy;
    } OBJATTR;

この OBJATTR の配列 (128 個分) が OAM と呼ばれるメモリ空間に格納されていますので、 スプライトダブラではラスタ割り込みの処理で OAM を書き換えることになります。
■ 方針
スプライトダブラを実装するにあたっては、ラスタ割り込みの処理を工夫する必要があります。 というのも、割り込み処理はあまり時間のかかる処理を行うことが出来ません。 ループをまわしてスプライト情報を書き換えることは現実的とは言い難いものがあります。
** 方針 [#tc656ca4]
割り込み中はあまり時間のかかる処理を行うことが出来ません。ループをまわしてスプライト情報を書き換えることは現実的とは言い難いものがあります。そこで事前に書き換え後のスプライト情報を用意しておいて、それらのデータをDMA転送させる方法を取ります。

そこで、事前に書き換え後のスプライト情報を用意しておいて、 ラスタ割り込みではそれらのデータを DMA 転送させる方法を取ります。
** 基本的な構造 [#x16dece7]
最大512個のスプライトを表示しようとする場合、512個分のOBJATTRデータを用意します。割り込みの際に少しづつデータをずらして上書きコピーしていけば、表示することが出来きるわけです。VCOUNT基準、つまりOBJATTRデータはy軸に沿ってソートされている必要があります。

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

    ソートされた 512 個の OBJATTR を用意する。
    ラスタ割り込みで、それらを上書きコピーする。
** 実装方式 [#hc944e96]
y軸を4ライン毎に区切って、それらを一塊のブロックとしてDMA転送します。以下は正直日記さんのソースコードを流用しました。

オマケ: 単純な構造のダブラ
BulletGBA から見る実装方式
■ BulletGBA のソースコードから見るスプライトダブラの実装
BulletGBA のソースコードのからスプライトダブラの実装を見てみます。 前提として、 BulletGBA では y 軸を 4 ライン毎に区切って、それらを一塊のブロックとして扱います。
- bullet,arm.h(抜粋)
 //---------------------------------------------------------------------------
 #define BULLET_MAX_CHR_CNT			512
 #define BULLET_MAX_IDX_CNT			42			// (縦画面160+非表示8) / 4
 
 #define INT2FIX(A)				((A)<<7)
 #define FIX2INT(A)				((A)>>7)
 
 //---------------------------------------------------------------------------
 
 typedef struct {
 	bool is;
 	s16  x;
 	s16  y;
 	s16  mx;
 	s16  my;
 
 } ST_BULLET_CHR;
 
 typedef struct {
 	s16  maxCnt;							// 最大数
 	s16  idxCnt[BULLET_MAX_IDX_CNT];				// yラインを41分割したカウント数
 
 	ST_BULLET_CHR chr[BULLET_MAX_CHR_CNT];				// 弾情報
 
 } ST_BULLET;

これは、 OAM への DMA 転送が最大 2 ライン分の時間がかかると考え、 最高でも 4 ライン毎でしか DMA 転送をスタートしないことに起因しています。 ラスタ割り込みが最大限発生する場合には VCOUNT が 0, 4, 8, 12, ..., 152 の場合のラスタ割り込みで DMA 転送をスタートさせます。
- bullet.arm.c(抜粋)
 //---------------------------------------------------------------------------
 IWRAM_CODE void BulletExec(void)
 {
 	_Memset(&Bullet.idxCnt, 0x00, sizeof(Bullet.idxCnt));
 
 	s32 i;
 
 	for(i=0; i<BULLET_MAX_CHR_CNT; i++)
 	{
 		if(Bullet.chr[i].is == FALSE)
 		{
 			continue;
 		}
 
 		Bullet.chr[i].x += Bullet.chr[i].mx;
 		Bullet.chr[i].y += Bullet.chr[i].my;
 
 		s32 xx = FIX2INT(Bullet.chr[i].x);
 		s32 yy = FIX2INT(Bullet.chr[i].y);
 
 		if(xx <= -8 || xx >= 240 || yy <= -8 || yy >= 160)
 		{
 			Bullet.chr[i].is = FALSE;
 			Bullet.maxCnt--;
 
 			continue;
 		}
 
 		s32 idx = (yy + 8) / 4;
 		Bullet.idxCnt[idx]++;
 	}
 }

その場合、一度の DMA 転送で扱うスプライトは最大 4 ライン分、つまり一塊のブロック分になります。
まず4ライン毎に区切られた各ブロックにそれぞれ幾つスプライト(弾)があるかカウントします。例えばBullet.idxCnt[0]は-7〜-5ラインに含まれる数。Bullet.idxCnt[1]は-4〜-1ラインに含まれる数といった形です。次に、512個分のOBJATTRに対してソートを行います。 

■ ソートされた 512 個の OBJATTR の用意
 //---------------------------------------------------------------------------
 IWRAM_CODE void SprExec(void)
 {
 	SprInitItem();
 
 	s32 i, cnt = 0; 
 	ST_SPR_ITEM* pW[SPR_MAX_IDX_CNT];
 
 	for(i=0; i<SPR_MAX_IDX_CNT; i++)
 	{
 		// 各VCOUNTでDMA転送するバッファ位置を格納
 		Spr.idx[i] = cnt;
 
 		// ソートで使うワークポインタを格納
 		pW[i] = &Spr.item[cnt];
 
 		cnt += Bullet.idxCnt[i];
 	}
 
 	// 弾情報ポインタを取得
 	ST_BULLET_CHR* pS = (ST_BULLET_CHR*)&Bullet.chr;
 
 	// 最初の弾情報を取得
 	while(pS->is == FALSE)
 	{
 		pS++;
 	}
 
 	// ソート開始
 	s32 max = Bullet.maxCnt;
 
 	for(i=0; i<max; i++)
 	{
 		s32 x  = FIX2INT(pS->x);
 		s32 y  = FIX2INT(pS->y);
 		u32 y4 = (y + 8) / 4;
 
 		pW[y4]->attr0 = OBJ_16_COLOR | OBJ_SQUARE | (y & 0x00ff);
 		pW[y4]->attr1 = OBJ_SIZE(0)  | (x & 0x01ff);
 		pW[y4]->attr2 = (1+Spr.chrNo);
 		pW[y4]++;
 
 		// 次の弾情報を取得
 		do {
 			pS++;
 
 		} while(pS->is == FALSE);
 	}
 
 	// スプライトアニメーション
 	Spr.chrNo++;
 	Spr.chrNo &= 0x03;
 }

    class SpriteDoubler {
      static const u32 LINEBLOCK = 4;
      static const u32 MAXITEM = 512;
      static const u32 NUM_BLOCK = 160 / LINEBLOCK;
4ライン毎に区切られた各ブロック数を計算し、各ブロック先頭の開始ワークポインタを確定させます。後半では弾情報を頭から順番に見ていって、各ワークポインタを通してソートを行います。

      class CompiledObjattr {
        OBJATTR sortedOBJATTR[MAXITEM]; // 512 個分のソート済み OBJATTR
** 割り込みによるDMA転送 [#a76c368e]
ソートが決まったら次は表示についてです。まずVBlank割り込みでOAMの0クリアを行います。その後にVCOUNT毎に弾情報を流し込んでいきます。

        u32 itemNumInBlock[NUM_BLOCK]; // 各ブロックに含まれるスプライトの数
 //---------------------------------------------------------------------------
 IWRAM_CODE void SprVBlank(void)
 {
 	// 0クリア OAM 
 	REG_DMA0SAD = (u32)&Spr.zero;
 	REG_DMA0DAD = (u32)OAM;
 	REG_DMA0CNT = (u32)128*2 | (DMA_SRC_FIXED | DMA_DST_INC | DMA32 | DMA_ENABLE);
 
 
 	Spr.oamCnt = 0;
 	Spr.vCnt = 0;
 
 	REG_DISPSTAT = (REG_DISPSTAT & STAT_MASK) | LCDC_VCNT | VCOUNT(SprVmap[Spr.vCnt]);
 }

        OBJATTR *objattrStartPosInBlock[NUM_BLOCK]; // sortedOBJATTR に対するポインタ。 各ブロックに対応するコピー先エリア。
      };
    };
注意点としては、n番目のブロックを転送するには、4ライン前のVCOUNT、つまり(n - 1) * 4ライン目で割り込みを発生させる必要があります。このタイミングで転送をスタートしておけば、実際にnブロック目が描写されるタイミング(n * 4ライン目) にはOAMへのDMA転送が終了している計算になります。なお転送ウェイトを考慮して-4ライン分も加えることにしました。

上で見たように、 BulletGBA では 4 ラインを一塊として見るので、 OBJATTR のソートも正確に y 軸に沿っていなくても 4 ライン分は同一視してかまわないことになります。
 // VCOUNT割り込みライン
 s32 SprVmap[SPR_MAX_IDX_CNT+1] = 
 {
 	218,	// 0		218 = 228 - 8 - 4
 	222,	// 1
 	226,	// 2
 	0,	// 3
 	4,	// 4
 	8,	// 5
 	12,	// 6
 	16,	// 7
 	20,	// 8
 	24,	// 9
 	28,	// 10
 	32,	// 11
 	36,	// 12
 	40,	// 13
 	44,	// 14
 	48,	// 15
 	52,	// 16
 	56,	// 17
 	60,	// 18
 	64,	// 19
 	68,	// 20
 	72,	// 21
 	76,	// 22
 	80,	// 23
 	84,	// 24
 	88,	// 25
 	92,	// 26
 	96,	// 27
 	100,	// 28
 	104,	// 29
 	108,	// 30
 	112,	// 31
 	116,	// 32
 	120,	// 33
 	124,	// 34
 	128,	// 35
 	132,	// 36
 	136,	// 37
 	140,	// 38
 	144,	// 39
 	148,	// 40
 	152,	// 41
 	0,	// vblank待ち用
 };

そこで、まずは 4 ライン毎に区切られた各ブロックにそれぞれ幾つのスプライトが属するかをカウントします。 これらの個数が itemNumInBlock に格納されます。 例えば itemNumInBlock[0] は 0 〜 3 ラインに含まれるスプライトの数、 itemNumInBlock[1] は 4 〜 7 ラインに含まれるスプライトの数になります。
残りはVCOUNT毎の割り込みで、対応しているブロックのOBJATTRを転送していくだけです。DMA転送が2つあるのはリングバッファになっています。OAM領域の末端まで行ったら、転送の残りは先頭から開始する仕組みです。

次に、 512 個分の OBJATTR に対して、各ブロックに対応するコピー先エリアを設定します。 下記の makeObjAttrStartPosInBlock 関数で設定を行います。 各ブロックに所属するスプライトについて *objattrStartPosInBlock に attribute を設定すれば良いことになります。
 //---------------------------------------------------------------------------
 IWRAM_CODE void SprVCount(void)
 {
 	s32 idx = Spr.idx[Spr.vCnt];		// 転送するSpr.itemのインデックス
 	s32 cnt = Spr.idxCnt[Spr.vCnt];		// 転送する数
 	s32 all = Spr.oamCnt + cnt;		// OAMインデックス + 転送する数
 
 	s32 sad0 = idx;
 	s32 dad0 = Spr.oamCnt;
 	s32 cnt0 = cnt;
 
 	s32 sad1 = 0;
 	s32 dad1 = 0;
 	s32 cnt1 = 0;
 
 	if(all >= SPR_MAX_OAM_CNT)
 	{
 		cnt0  = all - SPR_MAX_OAM_CNT;
 		cnt1  = cnt - cnt0;
 		sad1  = idx + cnt0;
 	}
 
 
 	OBJATTR* pS = (OBJATTR*)&Spr.item;
 	OBJATTR* pD = (OBJATTR*)OAM;
 
 	if(cnt0 != 0)
 	{
 		REG_DMA0SAD = (u32)&pS[sad0];
 		REG_DMA0DAD = (u32)&pD[dad0];
 		REG_DMA0CNT = (u32)cnt0*2 | (DMA_SRC_INC | DMA_DST_INC | DMA32 | DMA_ENABLE);
 	}
 
 	if(cnt1 != 0)
 	{
 		REG_DMA1SAD = (u32)&pS[sad1];
 		REG_DMA1DAD = (u32)&pD[dad1];
 		REG_DMA1CNT = (u32)cnt1*2 | (DMA_SRC_INC | DMA_DST_INC | DMA32 | DMA_ENABLE);
 	}
 
 
 	Spr.oamCnt += cnt;
 	Spr.oamCnt &= (SPR_MAX_OAM_CNT - 1);
 
 	Spr.vCnt++;
 	REG_DISPSTAT = (REG_DISPSTAT & STAT_MASK) | LCDC_VCNT | VCOUNT(SprVmap[Spr.vCnt]);
 }

        void makeObjAttrStartPosInBlock() {
          objattrStartPosInBlock[0] = sortedOBJATTR;
          for (u32 i = 0; i < NUM_BLOCK - 1; ++i) {
            objattrStartPosInBlock[i + 1] = objattrStartPosInBlock[i] + itemNumInBlock[i];
          }
        }
実際にはコピー先がOAMの最後に達した際には、wrap処理を行うなど細かい制御が必要になります。基本的には上記のようなロジックで割り込み処理を行います。

このように事前準備を行った上でソート済み OBJATTR を作成します。 大雑把に下記のような処理で sortedOBJATTR を作成していきます。
** オマケ単純な構造のダブラ [#n80470da]
上記の構造だとコピー先のOAMアドレスが毎回違っていたり、あらかじめ用意する512個分のスプライトメモリは隙間なく並べておかないといけないなど、ロジックが複雑になりがちです。そこで40個のOBJATTRからなるブロックを割り込みの毎にコピーしていく形にします。コピー先のOAMアドレスは3種類で固定されていますし、あらかじめ用意するスプライトメモリの構造も単純です。ただし、この構造の場合、一度に扱えるスプライト数が40個に制限されてしまうので、表示数を限界まで高めたい場合には上記の構造を取る必要があります。

        void registObjAttr(u32 attr0, u32 attr1, u32 attr2) {
          u32 idx = attr0 / LINEBLOCK;
** 512個まで表示したスプライト [#xf0b528a]
#ref(3.png,nolink)

          OBJATTR *p = objattrStartPosInBlock[idx];
** 参考リンク [#df5fe886]
- [[X68000 対応スプライト管理システムの実装:https://yosshin4004.github.io/x68k/xsp/index.html]]

          p->attr0 = attr0;
          p->attr1 = attr1;
          p->attr2 = attr2;
** 出典元 [#pb4ac969]
- GBA で学ぶ古典的プログラミング (スプライトダブラ)
- 弾速度調整君

          ++(objattrStartPosInBlock[idx]);
        }
** 履歴 [#j0abcb62]
- 2023/05/12

こうすることで、ソート済み 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)


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


トップ   一覧 検索 最終更新   ヘルプ   最終更新のRSS