ホーム < ゲームつくろー! < DirectX技術編
その69 インスタンシングでモデルを大量発生!
久しぶりのDirectX9技術編。2014.9時点でDirectX11な時代ではありますが、3Dの基礎はDirectX9でも十分に学べます。まだまだ現役DX9(^-^)
3Dのモデルには、例えば主人公クラスのキャラクタのように1点物モデルもありますが、パーティクルやモブキャラ(背景に適当にいるキャラクタ)のように同じ形状のモデルで且つ大量に発生する役目をするモデルもあります。STGも全く同じ弾や敵のモデルが実に沢山出ますよね。またノベルス系やRPGなどで大量に表示するフォント文字は、これ同じ板ポリゴンの形で表示位置とUV(フォントの位置)が違うだけだったりします。このように「同型もしくは似てるモデルを大量に発生する」場合、個々のモデルを1個ずつまともに描画していると大幅にパフォーマンスを犠牲にしてしまいます。
そういう状況の時に使えるのが「インスタンシング(Instancing)」です。この章ではDirectX9でのインスタンシングについて試行錯誤してみます。
@ インスタンシングの仕組み
インスタンシングとはどういう事をする物なのか早速見て行きましょう。通常1つのモデルを描画するには、
・ 頂点バッファ(XYXW、UVなど)
・ インデックスバッファ
・ シェーダ定数(ワールド変換行列、diffuseなど)
・ テクスチャ(サンプラ)
が必要です。これらを模式的に表すとこんな感じになります:
1つのモデルを描画するのにも上のような沢山の情報が必要なのに、そういうモデルが何百もあると当然描画負荷は高くなってしまいます。もちろん、ゲームの中に存在するモデルは様々ですから、1モデルずつ描画しなければならないのは事実。でも、例えばこういう状況があったらどうでしょうか?
画面に似たようなモデルを沢山描画する必要があるとします。個々のモデルはメッシュ(頂点バッファ、インデックスバッファ)やテクスチャなどは全く一緒です。しかしその位置(ワールド変換行列)だけが違う。そういうモデル群を通常の方法で描画すると次のようなプロセスになります:
青い枠は1回の描画を表しています。3回の描画で中身は殆ど一緒なんですが、シェーダパラメータにあるワールド変換行列だけが違います。この描画がこの後何百も続く…これ、何だか凄くもったいと思いませんでしょうか?他は全部同じなのに、ワールドでの姿勢が違うが為に毎回描画パイプラインにすべての情報を設定し直して都度描画(Drawコール)を繰り返す。1回の描画プロセスは長い長い工程を経る必要がありますから、トータルするとこのままではむちゃくちゃコスト高になってしまいます。
「共通部分はそのままに、違う部分だけを差し替えて一気に描画してくれないものか…」。つまり、こんな描画が出来たら最高です:
左の枠内のシェーダパラメータにワールド変換行列がありません。替わりに右に新しい枠が出来て、そこに描画すべき全モデルのワールド変換行列だけがゴソッと集まったバッファがあります。Drawコールはたったの1回。1回ですがパイプラインの中では右の差分部分からワールド変換行列を一つずつ取り出し、左の「共通部分」と合わせてモデルの数だけ描画してくれる。そう、こういう事をしてくれるのが「インスタンシング」なんです(^-^)。
インスタンシングは通常の方法をあれこれ技巧的に操作して出来るものではなくて、れっきとしたDirectX9の機能の一つです。
A ポイントは「ストリーム分割」
インスタンシング描画の方法を見て行きましょう。上の図にあるワールド変換行列の束には「シェーダパラメータ」とありますが、シェーダの定数領域(浮動小数点定数レジスタ)に大量の配列を用意するわけではありません。つまり、
シェーダ内の定数レジスタを使うわけではない float4x4 worlds[100];
VS_OUT vsMain( VS_IN In ) {
....
}
というようにグローバルな所に配列を用意する訳では無いんです。インスタンシングで使うのは「入力レジスタ」、すなわち頂点シェーダの引数に入って来るセマンティクスで識別されるレジスタを使うんです:
インスタンシングデータは入力レジスタに渡す! struct VS_IN {
float3 pos : POSITION;
float2 uv : TEXCOORD0;
float4 color : COLOR0;
float4 world0 : TEXCOORD1;
float4 world1 : TEXCOORD2;
float4 world2 : TEXCOORD3;
float4 world3 : TEXCOORD4;
};
VS_OUT vsMain( VS_IN In ) {
....
}
上のシェーダコードのようにTEXCOORDを利用する事が多いですが、その他のレジスタも利用できます。ここで「ちょーっと待って待って」と感じた方もいらっしゃるかもしれません。そうなんです、こういう頂点シェーダの引数にすると普通の描画では大変ヤバい事になります。頂点シェーダの引数には「1つの頂点の情報」が入ってきます。そこにワールド変換の値があると言う事は、1つの頂点毎にその値を用意しなければなりません。それはそれはとんでもない情報量になってしまいますよね。もちろん、そんな事はありません(^-^;。
インスタンシングには「共通部分」と「モデルごとの差分部分」があるという事を再度イメージして下さい。これらは別々の頂点バッファとして定義し、描画時に一緒に渡します。そう、インスタンシングはマルチストリーム描画なんです。
通常頂点バッファの情報は「ストリーム」にセットするとDrawコールでパイプラインに流してもらえます。コードでこんなの良く書きますよね:
頂点バッファを0番のストリームに dev->SetStreamSource( 0, vertexBuffer, 0, sizeof( Vtx ) );
devはIDirect3DDevice9、vertexBufferは頂点情報が格納されているIDirect3DVertexBuffer9、Vtxは1頂点を構成する構造体です。インスタンシングをする場合、複数の頂点バッファを用意してそれぞれ別のストリームにセットして流してもらうんです。先の例の場合ならこんな感じの実装になります:
複数の頂点バッファを複数のストリームに dev->SetStreamSource( 0, vertexBuffer, 0, sizeof( Vtx ) );
dev->SetStreamSource( 1, matrixBuffer, 0, sizeof( D3DXMATRIX ) );
ストリーム0番に共通の頂点バッファ、1番にワールド変換行列の束(matrixBuffer)を渡しています。これでDrawコールすると、頂点シェーダにはこれらのストリームの情報を合わせた値が引数に渡されます。
しかしながら、このままだとまだ描画は成立しません。例えば1モデルに頂点が100点あるとします。ですからvertexBufferの要素数は100です。一方そのモデルを8個描画したいとすると、matrixBufferの要素数は8になります。しかし上のままだとシェーダ内では、
// インデックス75番の頂点情報を頂点シェーダに渡そうとする時のイメージ
vertexBuffer[ 75 ]; // OK
matrixBuffer[ 75 ]; // Over!!
このように両方のバッファに対して等しいインデックスで情報を指してしてしまいます。8個しか要素の無いmatrixBuffer[ 75 ]が不正なのは明らかです。インスタンシングをするにはこのままではまずいわけです。これをうまくしてしまうカラクリは「SetStreamSourceFreq」にあります(^-^)/
B IDirect3DDevice9::SetStreamSourceFreqメソッドがまるっと解決!
実は、DirectX9にはストリームにセットされているバッファから特殊な方法で情報を取得する仕組みが備わっています。それを担うのがIDirect3DDevice9::SetStreamSourceFreqメソッドです。このメソッドを通し各ストリームに対して「周波数パラメータ」という物を設定します。概念図で説明しますね:
図で赤い文字で示しているのが周波数パラメータの一つで、そのストリームに置かれているのが通常のインデックスで参照する頂点バッファである(INDEXED DATA)か、インスタンシング用の1モデル毎に適用されるデータの束である(INSTANCE DATA)かどちらかを指定します。Stream0は頂点バッファが設定されるのでINDEXED DATA、Stream1は1つのモデルに対応したワールド変換行列の束が渡されるのでINSTANCE DATAです。
各ストリームにはもう一つ「周波数分割」という値も設定する必要があります。これはINDEXED DATAとINSTANCE DATAでちょっと意味が違うのかなぁと(リファレンスにもあまり情報が無い…)のですが、INDEXED DATAの場合は「共通部分を繰り返す回数」、INSTANCE DATAは「インスタンスの束の一つを繰り返す数」になります。ですから上の図でもStream0側の周波数分割数はモデルの数(ModelNum)、インスタンスデータ群であるStream1に対しては「1」を設定しています。
これを実際に設定する実装コードは以下のようになります:
ストリーム周波数を設定 dev->SetStreamSourceFreq( 0, D3DSTREAMSOURCE_INDEXEDDATA | modelNum );
dev->SetStreamSourceFreq( 1, D3DSTREAMSOURCE_INSTANCEDATA | 1 );
第1引数はストリーム番号、第2引数はストリーム周波数のタイプ(INDEXED DATA, INSTANCE DATA)と周波数分割数をORで合成して渡します。ちょっと変わってますね。
この設定をする事で初めてインスタンス描画が成立します。
B 各ストリームの頂点宣言
インスタンス描画は複数のストリームに複数の頂点バッファを渡します。頂点宣言もマルチストリーム用の宣言方法になります。
各ストリームに渡す頂点宣言はD3DVERTEXELEMENT9構造体で記述します。例えば、インデックスデータであるStream0に頂点座標(XYZ)とUVがあり、インスタンスデータであるワールド変換行列がTEXCOORD1〜4までの4つを使う場合のそれぞれの頂点宣言は次のようになります:
ストリーム別の頂点宣言 D3DVERTEXELEMENT9 declElems[] = {
// Stream0
{0, 0, D3DDECLTYPE_FLOAT3, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_POSITION, 0}, // XYZ
{0, 12, D3DDECLTYPE_FLOAT2, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_TEXCOORD, 0}, // UV
// Stream1
{1, 0, D3DDECLTYPE_FLOAT4, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_TEXCOORD, 1}, // WORLD 1行目
{1, 16, D3DDECLTYPE_FLOAT4, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_TEXCOORD, 2}, // WORLD 2行目
{1, 32, D3DDECLTYPE_FLOAT4, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_TEXCOORD, 3}, // WORLD 3行目
{1, 48, D3DDECLTYPE_FLOAT4, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_TEXCOORD, 4}, // WORLD 4行目
D3DDECL_END()
};
Stream0用のは、まぁ普通の頂点宣言です(^-^;。Stream1用のもTEXCOORD1〜4までを使うと宣言しているだけです。マルチストリームでのオフセット値はそれぞれのストリームで0から始まります。このdecElems配列をIDirect3DDevice9::CreateVertexDeclarationメソッドに渡せばインスタンシング用の頂点宣言の出来上がりです。
このように頂点シェーダの引数に1モデル毎の共通パラメータを渡せるというとってもご機嫌なインスタンシングですが、一つ注意があります。頂点シェーダの入力レジスタ数は限りがあります。インスタンスデータ用として主に使用するTEXCOORDの添え字も無限に使える訳ではありません。シェーダバージョン2.x及び3.0では入力レジスタの数は16個しかありません(レジスタ - vs_2_x、レジスタ - vs_3_0)。つまりTEXCOORDは0〜15までと制約があります。もちろんPOSITIONやUVなども入力レジスタを占有しますので、実際はもっと少なくしか使えません。
ここまでのお話をまとめると、インスタンシングで沢山のモデルを一気に描画するには
・ 共通部分とモデル毎のパラメータの束であるインスタンスデータとに頂点バッファを分けて定義
・ IDirect3DDevice9::SetStreamSourceにそれぞれの頂点バッファをデバイスに設定
・ インデックスバッファをデバイスに設定
・ それぞれの頂点宣言を上のような感じで定義しデバイスに設定
・ IDirect3DDevice9::SetStreamSourceFreqメソッドで周波数分割を設定。INDEXED DATAならモデル数、INSTANCE DATAなら1
・ IDirect3DDevice9::DrawIndexedPrimitiveメソッドで描画!
となります。ではここまでのインスタンシング機能の理屈を踏まえて、インスタンシングを用いた具体的な描画を行ってみましょう。
C チップベースゲームの板ポリ
ファミコンのように「チップタイリング」で画面を構成するゲームは未だに沢山出ています。世界中で爆発的にヒットしているマインクラフトとかテラリア等はその代表作ですね。マインクラフトは立方体がチップベースになっているのに対してテラリアでは縦横数ピクセルの小さな画像をチップとして画面に大量に敷き詰めてワールドを構成しています。チップベースのゲームはチップを取り換える事で非常に細かな表現ができるため、リアルタイムに穴を掘るとか家を建てるなどクラフティングなゲームにとても向いています。
テラリアで具体的にどの位画面にチップを敷きつめているか具体的に計算してみましょう。下の絵はテラリア(Steam版)のマップの一部を拡大した物です:
拡大サイズで見た時に8×8ピクセルっぽく見えますが、画面上でのチップサイズは縦横16ピクセルのようです。で、私のPCの場合、フルスクリーン(クライアントサイズ:1366×706)にするとこのチップが横85枚、縦44枚、合計で3740枚敷き詰められていました。これをインスタンシングを使って描画してみます。
サンプルなのでスクロールなど面倒臭い所は抜きにして、「画面内を隙間なく16×16の板ポリで埋め尽くし、すべての板ポリに指定したチップをインスタンシングを使って描画する」というのをゴールとします。
○ 板ポリを定義
まずは共通の頂点バッファとなる板ポリゴンを定義しましょう。下の図をご覧ください:
こういうスクリーン空間での頂点座標とUV値を持った4頂点を頂点バッファに格納します。uはチップを並べているテクスチャの1チップのUV幅(TX)です。正方形のテクスチャの幅をTxピクセルとすると、チップの幅(16px)からu = 16/TXと計算されます。
この板ポリに対応するインデックスバッファは{ 0, 1, 2, 2, 1, 3 }という値になります。
一方、インスタンスデータは実は2種類作ります。一つは板ポリゴンに貼り付ける「チップの左上UV座標」です。このUV座標を元に頂点シェーダ内で対応するチップ絵を板ポリに貼り付けます。これはStream1に設定します。もう一つは「板ポリゴンの左上XY座標」群です。これはローカル座標で定義されている板ポリゴンを世界に並べるために必要で、Stream2に設定する事にしましょう。
画面に板ポリを並べる時の頂点シェーダの計算イメージはこんな感じになります:
頂点バッファ、チップUVバッファ、ワールド位置バッファはそれぞれ次のようなコードで定義されます:
バッファ作成 struct Vtx { // 頂点バッファ
float x, y;
float u, v;
};
struct UV { // チップのUVバッファ
float u, v;
};
struct WorldPos { // 板ポリのワールド座標位置バッファ
float x, y;
};
const float screenW = 640.0f; // スクリーン幅
const float screenH = 480.0f; // スクリーン高
const int texPx = 64; // テクスチャのピクセルサイズ
const int tipPx = 16; // チップのピクセルサイズ
const float u = (float)tipPx / texPx; // チップのUVサイズ
const int tipNumInTex = texPx / tipPx; // テクスチャ内のチップの並び数
const int W = 640 / tipPx; // スクリーンに並べる板ポリの横数
const int H = 480 / tipPx; // スクリーンに並べる板ポリの縦数
const int tipNum = W * H; // スクリーン上のチップ総数
Vtx vtx[4] = { // 単位板ポリバッファ
{ 0.0f, 0.0f, 0.0f, 0.0f },
{ tipPx, 0.0f, u, 0.0f },
{ 0.0f, tipPx, 0.0f, u },
{ tipPx, tipPx, u, u }
};
UV *uv = new UV[ tipNum ]; // チップUVバッファ
for ( int i = 0; i < tipNum; i++ ) {
uv[i].u = u * (rand() % tipNumInTex);
uv[i].v = u * (rand() % tipNumInTex);
}
WorldPos *worldPos = new WorldPos[ tipNum ]; // ワールド座標位置バッファ
for ( int w = 0; w < W; w++ ) {
for ( int h = 0; h < H; h++ ) {
int e = h * W + w;
worldPos[ e ].x = tipPx * w;
worldPos[ e ].y = tipPx * h;
}
}
// 頂点バッファ作成
IDirect3DVertexBuffer9 *vtxBuf = 0, *uvBuf = 0, *worldPosBuf = 0;
g_pD3DDev->CreateVertexBuffer( sizeof( vtx ), 0, 0, D3DPOOL_MANAGED, &vtxBuf, 0 );
g_pD3DDev->CreateVertexBuffer( sizeof( WorldPos ) * tipNum, 0, 0, D3DPOOL_MANAGED, &worldPosBuf, 0 );
g_pD3DDev->CreateVertexBuffer( sizeof( UV ) * tipNum, 0, 0, D3DPOOL_MANAGED, &uvBuf, 0 );
copyBuf( sizeof( vtx ), vtx, vtxBuf );
copyBuf( sizeof( WorldPos ) * tipNum, worldPos, worldPosBuf );
copyBuf( sizeof( UV ) * tipNum, uv, uvBuf );
delete[] uv;
delete[] worldPos;
// インデックスバッファ
WORD index[6] = { 0, 1, 2, 2, 1, 3 };
IDirect3DIndexBuffer9 *indexBuf = 0;
g_pD3DDev->CreateIndexBuffer( sizeof( index ), 0, D3DFMT_INDEX16, D3DPOOL_MANAGED, &indexBuf, 0 );
void *p = 0;
indexBuf->Lock( 0, 0, &p, 0 );
memcpy( p, index, sizeof( index ) );
indexBuf->Unlock();
// 頂点宣言作成
D3DVERTEXELEMENT9 declElems[] = {
{ 0, 0, D3DDECLTYPE_FLOAT2, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_POSITION, 0 }, // Local coord
{ 0, 8, D3DDECLTYPE_FLOAT2, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_TEXCOORD, 0 }, // UV
{ 1, 0, D3DDECLTYPE_FLOAT2, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_TEXCOORD, 1 }, // ワールド位置
{ 2, 0, D3DDECLTYPE_FLOAT2, D3DDECLMETHOD_DEFAULT, D3DDECLUSAGE_TEXCOORD, 2 }, // チップのUV
D3DDECL_END()
};
IDirect3DVertexDeclaration9 *decl = 0;
g_pD3DDev->CreateVertexDeclaration( declElems, &decl );
ちょっとごちゃっとしてますが、やっている事は先に説明した通りです。頂点宣言でワールド位置とチップUVをストリームに分けてTEXCOORDを割り当てているのに注目です。
○ シェーダ
スクリーン空間ベースの板ポリが頂点シェーダの引数に流れてきます。それをワールドに並べて、チップを指すUVを設定するシェーダコードはこんな感じになります:
シェーダコード float screenW; // スクリーン幅
float screenH; // スクリーン高
texture tex;
sampler tipSampler = sampler_state {
texture = <tex>;
MipFilter = LINEAR;
MinFilter = POINT;
MagFilter = POINT;
};
struct VS_OUT {
float4 pos : POSITION;
float2 uv : TEXCOORD0;
};
VS_OUT vsMain(
float2 pos : POSITION,
float2 localUV : TEXCOORD0,
float2 worldPos : TEXCOORD1,
float2 tipUV : TEXCOORD2
) {
VS_OUT Out;
Out.pos = float4(
( pos.x + worldPos.x - screenW ) / screenW, // 射影空間のX座標に変換
-( pos.y + worldPos.y - screenH ) / screenH, // 射影空間のY座標に変換
0.0f,
1.0f
);
Out.uv = tipUV + localUV;
return Out;
}
float4 psMain( VS_OUT In ) : COLOR0 {
return tex2D( tipSampler, In.uv );
}
technique tech {
pass p0 {
VertexShader = compile vs_3_0 vsMain();
PixelShader = compile ps_3_0 psMain();
}
}
頂点シェーダの引数に板ポリの頂点(pos, localUV)、ワールド位置(worldPos)、チップのUV(tipUV)が入ってきます。頂点シェーダの出力は射影空間での座標(Out.pos)とチップを指すUV(Out.uv)です。
スクリーン座標を射影空間に変換しているのがOut.posを計算している所です。射影空間の(-1,-1,0)〜(1, 1, 1)の範囲にあるポリゴンがスクリーンに引き延ばされるので、その逆変換をしています。UVについてはローカルUVにチップの左上UVの値を足すだけです。
ピクセルシェーダはうんと単純で、チップのテクスチャからチップの絵をフェッチするだけでOKです。
○ インスタンス描画
肝心のインスタンス描画は、ここまで準備が出来ていれば割と単純です:
インスタンス描画 g_pD3DDev->Clear( 0, NULL, D3DCLEAR_TARGET | D3DCLEAR_STENCIL | D3DCLEAR_ZBUFFER, D3DCOLOR_XRGB( 0, 0, 255 ), 1.0f, 0 );
g_pD3DDev->BeginScene();
// インスタンス宣言
g_pD3DDev->SetStreamSourceFreq( 0, D3DSTREAMSOURCE_INDEXEDDATA | (W*H) );
g_pD3DDev->SetStreamSourceFreq( 1, D3DSTREAMSOURCE_INSTANCEDATA | 1 );
g_pD3DDev->SetStreamSourceFreq( 2, D3DSTREAMSOURCE_INSTANCEDATA | 1 );
// 頂点とインデックスを設定して描画
g_pD3DDev->SetVertexDeclaration( decl );
g_pD3DDev->SetStreamSource( 0, vtxBuf, 0, sizeof( Vtx ) ); // 板ポリ頂点バッファ
g_pD3DDev->SetStreamSource( 1, worldPosBuf, 0, sizeof( WorldPos ) ); // ワールド位置バッファ
g_pD3DDev->SetStreamSource( 2, uvBuf, 0, sizeof( UV ) ); // チップUVバッファ
g_pD3DDev->SetIndices( indexBuf );
effect->SetTechnique( "tech" );
UINT passNum = 0;
effect->Begin( &passNum, 0 );
effect->BeginPass( 0 );
effect->SetTexture( "tex", tex );
effect->SetFloat( "screenW", screenW / 2 );
effect->SetFloat( "screenH", screenH / 2 );
g_pD3DDev->DrawIndexedPrimitive( D3DPT_TRIANGLELIST, 0, 0, 4, 0, 2 );
effect->EndPass();
effect->End();
// 後始末
g_pD3DDev->SetStreamSourceFreq( 0, 1 );
g_pD3DDev->SetStreamSourceFreq( 1, 1 );
g_pD3DDev->SetStreamSourceFreq( 2, 1 );
g_pD3DDev->EndScene();
g_pD3DDev->Present( NULL, NULL, NULL, NULL );
SetStreamSourceFreqメソッドでストリームをインスタンス用にセットアップし、後はストリームに各頂点バッファを流し、インデックスで描画を実行します。Drawコールはこの1回だけ。これで画面中に板ポリゴンが並ぶのですから面白いです。後始末の所がポイントです。これをやっておかないと、次の描画に影響してしまいます。
以上の実装を基盤とした完全なサンプルコードはこちらで公開致します(^-^)
D インスタンシングの効果は?
ここまでの説明と実装例から、インスタンシングをするにはちょっと下準備に手間がかかるのがわかると思います。この手間に見合う効果はあるのでしょうか?公開しているサンプルプログラムでは同じチップ数を描画する時に「インスタンシング」と「通常」とで切り替えられるようにしています。インスタンシングの方が1回のDrawコールで描画しているのに対し、通常の方は1枚1枚丁寧にDrawコールしています。
では、その効果をご覧下さい:
描画しているチップの数は共に1200枚。左が通常、右がインスタンシング描画です。赤線で示した時間は1回での描画時間を秒で示しています。これを見ると驚異的な違いが見て取れます。通常の方は2.3ms程かかっているのに対し、インスタンシング描画は何と0.03msです!(共にReleaseビルド)。私も実際に描画を走らせて腰が砕けそうになりました(^-^;。インスタンシング…恐るべし!
E マップ上のチップを変えるには?
固定的に作った頂点バッファを通したインスタンシング描画が凄く速い事はわかりました。しかし、クラフティングするゲームではつるはしで穴を掘ったり爆弾で一部を破壊したりなど、マップ内のチップが変わります。それはどうするか?
マップチップはチップ用のUVバッファの値で指定しています。ですからそのバッファの値を変えれば良いのですが、UVバッファはIDirect3DVertexBuffer9が管理しているため、書き換えるにはLockする必要があります。インスタンシング描画をする時に一つ問題になるのがこのLockのコストです。Lockは呼び出すだけでも少し時間を取られますし、メモリへの書き込みサイズが大きくなると当然書き換えコストもかかってきます。
ただ、多くの場合マップの殆どの場所は変化しないもんです。フレームを更新する時に変化する部分だけをリストアップし、UVバッファを1回ロックして一気に書き換えてしまえば、そのコストは大変小さくなります。特定のチップをコリコリと掘る程度であれば全く問題ありません。
この章ではDirectX9が用意しているインスタンシングについて見てきました。どこまでこれを使えるかは作るゲームによりますが、うまく使うと凄まじくパフォーマンスが上がる仕組みでもあります。チップをフォント文字に置き換えれば文字出し放題ですし、弾に置きかえれば弾の描画コストは大幅に減ります(Lockコストとの兼ね合いになります)。3Dな物でも不透明なモデルであれば同じように一気に描画できます。ただ、3D空間での半透明な描画をするのにはZソートが必要になるのでちょっと厳しいかもしれません。使い所を見定めてうまく組み入れたいもんです(^-^)