- 追加された行はこの色です。
- 削除された行はこの色です。
#author("2023-05-10T22:51:12+09:00","","")
#freeze
#author("2023-06-15T19:21:09+09:00","","")
* スプライトダブラ [#e3a083e3]
スプライトダブラとは、VCOUNT割り込みでスプライト情報を書き換える方法です。この技術をGBAで用いると128個以上のスプライトを一度に表示することが可能になります。昔のハードウェア、例えばX68000やMSXなどの時代に限られたスプライトを有効利用するために使われていました。単純な例ですと以下のようにスプライト0を画面の左上に表示していたとします。赤線は現在の描画済みVCOUNTラインです。
スプライトダブラとは、VCOUNT割り込み(ラスタ割り込み)でスプライト情報を書き換える方法です。この技術を用いると128個以上のスプライトを一度に表示することが可能になります。昔のハードウェア、例えばX68000やMSXなどの時代でも使われていました。仮にスプライト0を画面の左上に表示したとします。赤線は現在のVCOUNTラインです。
#ref(1.png,nolink)
その際にスプライト0が表示された後のVCOUNT割り込みでスプライト0の位置を変更すると、スプライト0を画面に2つ表示することが出来ます。
スプライト0が表示された後、VCOUNT割り込みでスプライト0の位置を変更します。結果、画面に2つ表示することが出来ました。
#ref(2.png,nolink)
これをスプライト0〜スプライト127までに対して行えば、256個のスプライトを表示することが出来ます。
スプライト0〜スプライト127までに対して行えば256個を表示することが出来るという寸法です。
** 始めに [#w8cb0b00]
GBAではスプライトの情報を以下のような形で持っています。
GBAではスプライト情報を以下のような形で持っています。このOBJATTRの配列(128個分)がOAMと呼ばれるメモリ空間に格納されていますので、スプライトダブラではVCOUNT割り込み処理でOAMを書き換えることになります。
typedef struct {
u16 attr0; // Y 座標などのデータ
u16 attr1; // X 座標などのデータ
u16 attr2; // タイル情報、パレット情報などのデータ
u16 dummy;
} OBJATTR;
このOBJATTRの配列(128個分)がOAMと呼ばれるメモリ空間に格納されていますので、スプライトダブラではVCOUNT割り込みの処理でOAMを書き換えることになります。
** 方針 [#tc656ca4]
スプライトダブラを実装するにあたっては、割り込みの処理を工夫する必要があります。というのも割り込み処理はあまり時間のかかる処理を行うことが出来ません。ループをまわしてスプライト情報を書き換えることは現実的とは言い難いものがあります。そこで事前に書き換え後のスプライト情報を用意しておいて、ラスタ割り込みではそれらのデータをDMA転送させる方法を取ります。
割り込み中はあまり時間のかかる処理を行うことが出来ません。ループをまわしてスプライト情報を書き換えることは現実的とは言い難いものがあります。そこで事前に書き換え後のスプライト情報を用意しておいて、それらのデータをDMA転送させる方法を取ります。
** 基本的な構造 [#x16dece7]
スプライトダブラで最大512個のスプライトを表示しようとする場合、512個分のOBJATTRデータをあらかじめ用意しておき、ラスタ割り込みの際に少しづつデータをずらして上書きコピーしていけば512個のスプライトを表示することが出来ます。あらかじめ用意する 512個のOBJATTRはy軸に沿ってソートされている必要があります。
最大512個のスプライトを表示しようとする場合、512個分のOBJATTRデータを用意します。割り込みの際に少しづつデータをずらして上書きコピーしていけば、表示することが出来きるわけです。VCOUNT基準、つまりOBJATTRデータはy軸に沿ってソートされている必要があります。
- ソートされた 512 個の OBJATTR を用意する。
- ラスタ割り込みで、それらを上書きコピーする。
- ソートされた512個のOBJATTRを用意する。
- 割り込みで、それらをDMA上書きコピーする。
** 実装方式 [#hc944e96]
前提として、y軸を4ライン毎に区切って、それらを一塊のブロックとして扱います。これはOAMへのDMA転送が最大2ライン分の時間がかかると考え、最高でも4ライン毎でしかDMA転送をスタートしないことに起因しています。VCOUNTが0,4,8,12, ..., 152の場合の割り込みでDMA転送をスタートさせます。ちなみに4ラインに区切るということは
y軸を4ライン毎に区切って、それらを一塊のブロックとしてDMA転送します。以下は正直日記さんのソースコードを流用しました。
** ソートされた512個のOBJATTRの用意 [#u7a70178]
- 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;
class SpriteDoubler {
static const u32 LINEBLOCK = 4;
static const u32 MAXITEM = 512;
static const u32 NUM_BLOCK = 160 / LINEBLOCK;
- 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]++;
}
}
class CompiledObjattr {
OBJATTR sortedOBJATTR[MAXITEM]; // 512 個分のソート済み OBJATTR
まず4ライン毎に区切られた各ブロックにそれぞれ幾つスプライト(弾)があるかカウントします。例えばBullet.idxCnt[0]は-7〜-5ラインに含まれる数。Bullet.idxCnt[1]は-4〜-1ラインに含まれる数といった形です。次に、512個分のOBJATTRに対してソートを行います。
u32 itemNumInBlock[NUM_BLOCK]; // 各ブロックに含まれるスプライトの数
//---------------------------------------------------------------------------
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;
}
OBJATTR *objattrStartPosInBlock[NUM_BLOCK]; // sortedOBJATTR に対するポインタ。 各ブロックに対応するコピー先エリア。
};
};
4ライン毎に区切られた各ブロック数を計算し、各ブロック先頭の開始ワークポインタを確定させます。後半では弾情報を頭から順番に見ていって、各ワークポインタを通してソートを行います。
上で見たように、4ラインを一塊として見るので、OBJATTRのソートも正確にy軸に沿っていなくても4ライン分は同一視してかまわないことになります。
** 割り込みによるDMA転送 [#a76c368e]
ソートが決まったら次は表示についてです。まずVBlank割り込みでOAMの0クリアを行います。その後にVCOUNT毎に弾情報を流し込んでいきます。
そこで、まずは4ライン毎に区切られた各ブロックにそれぞれ幾つのスプライトが属するかをカウントします。これらの個数がitemNumInBlockに格納されます。 例えばitemNumInBlock[0]は0〜3ラインに含まれるスプライトの数、itemNumInBlock[1]は4〜7ラインに含まれるスプライトの数になります。
//---------------------------------------------------------------------------
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]);
}
次に、512個分のOBJATTRに対して、各ブロックに対応するコピー先エリアを設定します。 下記のmakeObjAttrStartPosInBlock関数で設定を行います。各ブロックに所属するスプライトについて*objattrStartPosInBlockにattributeを設定すれば良いことになります。
注意点としては、n番目のブロックを転送するには、4ライン前のVCOUNT、つまり(n - 1) * 4ライン目で割り込みを発生させる必要があります。このタイミングで転送をスタートしておけば、実際にnブロック目が描写されるタイミング(n * 4ライン目) にはOAMへのDMA転送が終了している計算になります。なお転送ウェイトを考慮して-4ライン分も加えることにしました。
void makeObjAttrStartPosInBlock() {
objattrStartPosInBlock[0] = sortedOBJATTR;
for (u32 i = 0; i < NUM_BLOCK - 1; ++i) {
objattrStartPosInBlock[i + 1] = objattrStartPosInBlock[i] + itemNumInBlock[i];
}
}
// 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待ち用
};
このように事前準備を行った上でソート済みOBJATTRを作成します。大雑把に下記のような処理でsortedOBJATTR を作成していきます。
残りはVCOUNT毎の割り込みで、対応しているブロックのOBJATTRを転送していくだけです。DMA転送が2つあるのはリングバッファになっています。OAM領域の末端まで行ったら、転送の残りは先頭から開始する仕組みです。
void registObjAttr(u32 attr0, u32 attr1, u32 attr2) {
u32 idx = attr0 / LINEBLOCK;
//---------------------------------------------------------------------------
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]);
}
OBJATTR *p = objattrStartPosInBlock[idx];
実際にはコピー先がOAMの最後に達した際には、wrap処理を行うなど細かい制御が必要になります。基本的には上記のようなロジックで割り込み処理を行います。
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転送 [#a76c368e]
まずは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処理を行うなど細かい制御が必要になりますが、基本的には上記のようなロジックでラスタ割り込み処理を行います。
制約の話書く
** オマケ単純な構造のダブラ [#n80470da]
上記の構造だとコピー先のOAMアドレスが毎回違っていたり、あらかじめ用意する512個分のスプライトメモリは隙間なく並べておかないといけないなど、ロジックが複雑になりがちです。 そこで、下記のような構造を取ると簡単にダブラを実装できます。
上記の構造だとコピー先のOAMアドレスが毎回違っていたり、あらかじめ用意する512個分のスプライトメモリは隙間なく並べておかないといけないなど、ロジックが複雑になりがちです。そこで40個のOBJATTRからなるブロックを割り込みの毎にコピーしていく形にします。コピー先のOAMアドレスは3種類で固定されていますし、あらかじめ用意するスプライトメモリの構造も単純です。ただし、この構造の場合、一度に扱えるスプライト数が40個に制限されてしまうので、表示数を限界まで高めたい場合には上記の構造を取る必要があります。
この場合、40個のOBJATTRからなるブロックをラスタ割り込みの毎にコピーしていく形になるので、コピー先のOAM アドレスは3種類で固定されていますし、あらかじめ用意するスプライトメモリの構造も単純です。
** 512個まで表示したスプライト [#xf0b528a]
#ref(3.png,nolink)
ただし、この構造の場合、一度に扱えるスプライト数が40 個に制限されてしまうので、スプライトの表示数を限界まで高めたい場合には上記の構造を取る必要があります。
フレーム開始時 oam-another01.png
ラスタ割り込み(1) oam-another02.png
ラスタ割り込み(2) oam-another03.png
** 参考リンク [#df5fe886]
- [[X68000 対応スプライト管理システムの実装:https://yosshin4004.github.io/x68k/xsp/index.html]]
** 出典元 [#pb4ac969]
- GBA で学ぶ古典的プログラミング (スプライトダブラ)
- 弾速度調整君
** 履歴 [#j0abcb62]
- 2023/05/11
- 2023/05/12