マスクの前処理方式(Native)
最終更新: 2023年2月21日
Live2D Cubism SDK for Nativeでは、スマートフォンなどで描画速度を維持するために、
モデル描画処理の最初に一枚のマスクバッファに対してすべてのマスク形状を描画する『前処理方式』を採用しています。
原則的な描画方法では、マスクを必要とするDrawableを描画するタイミングで、その都度マスク形状を描画します(図参照)。
この方法では、Drawableがマスクを必要とするたびにレンダーターゲットの切り替え・バッファのクリアなど比較的高コストな処理が発生することになります。
そのため、スマートフォンなどで描画速度が低下する原因になる場合があります。
しかし、前もってマスクを用意するだけではマスクバッファが複数枚必要になり、メモリーを圧迫することになります。
この点を解決するため、一枚のマスクバッファに対して以下の処理を行うことで、まるで複数枚のマスクバッファを利用しているかのように扱いつつ、メモリーの圧迫を抑えることができます。
マスクの統合
前もってすべてのマスクを生成するため、同じマスク指定を受けているDrawableは同一のマスク画像を使うことで生成する枚数を抑えられます。
この処理はCubismRenderer_OpenGLES2::Initialize関数呼び出しの中で
CubismClippingManager_OpenGLES2::Initialize関数によって行われます。
void CubismClippingManager_OpenGLES2::Initialize(CubismModel& model, csmInt32 drawableCount, const csmInt32** drawableMasks, const csmInt32* drawableMaskCounts) { //クリッピングマスクを使う描画オブジェクトを全て登録する //クリッピングマスクは、通常数個程度に限定して使うものとする for (csmInt32 i = 0; i < drawableCount; i++) { if (drawableMaskCounts[i] <= 0) { //クリッピングマスクが使用されていないアートメッシュ(多くの場合使用しない) _clippingContextListForDraw.PushBack(NULL); continue; } // 既にあるClipContextと同じかチェックする CubismClippingContext* cc = FindSameClip(drawableMasks[i], drawableMaskCounts[i]); if (cc == NULL) { // 同一のマスクが存在していない場合は生成する cc = CSM_NEW CubismClippingContext(this, drawableMasks[i], drawableMaskCounts[i]); _clippingContextListForMask.PushBack(cc); } cc->AddClippedDrawable(i); _clippingContextListForDraw.PushBack(cc); } }
複数枚のマスク用テクスチャの利用
Cubism SDK for Native R6以降では、マスク用テクスチャを任意で複数枚使用することができます。
そのため、R5までに存在したマスクの使用上限数である36枚を超過したマスクをモデルに設定しても、SDK上で正常に表示させることができるようになります。
ただし、マスク用テクスチャを2枚以上使用した場合、マスク用テクスチャ1枚に対して生成されるマスクの上限数は32枚までとなります。
(1枚のみ使用する場合のマスクの上限数は36枚です。こちらの詳細は後述します)
仮にマスク用テクスチャを2枚使用した場合、使用できるマスクの上限数は32 * 2の64枚になります。
マスク用テクスチャを複数枚使用するための設定は以下のようになります。
int maskBufferCount = 2; CubismRenderer renderer = Rendering::CubismRenderer::Create(); renderer->Initialize(cubismModel, maskBufferCount);
CubismRenderer::Initialize() の第2引数に何も渡さない場合、マスク用テクスチャは1枚のみ生成して使用されます。
renderer->Initialize(cubismModel);
色情報での分離
マスクバッファは実体として通常のテクスチャバッファなどと同じようにRGBAの映像用配列です。
通常のマスク処理ではこのAチャンネルのみを使用してマスクを適用しますがRGBのチャンネルは使用しません。
そこでRGBAで別々のマスクデータを持つことによって一枚のマスクバッファを4枚のマスクバッファとして取り扱えるようになります。
分割分離
マスク画像が4枚では足りなくなった時、マスクバッファを2分割、4分割、9分割で取り扱うことによってマスクの枚数を増やします。
色情報での分割もあるので4x9の36枚まで違うマスクを保持できるようになっています。
またマスク画像がつぶれるのを防ぐため、マスクの適用を受けるすべてのDrawableの矩形でマスクを描画します。
このため範囲の生成とマスク生成、マスク使用でのマトリックスの生成が必要になります。
マスク用テクスチャを複数枚利用する場合も分割方式はマスク用テクスチャ1枚のみを利用した場合と同様となります。
ただし、マスク用テクスチャを複数枚利用する場合にはマスク用テクスチャ1枚あたりへのマスクの割り当てを可能な限り等分するようになるため、同じモデルでも描画の品質が上がります。(マスク用テクスチャを増やすと、その分処理コストはかかります)
例えば、マスクが32枚のモデルの場合には通常はマスク用テクスチャ1枚で32枚のマスクを描画しようとしますが、マスク用テクスチャを2枚使った場合のマスクの割り当ては「1枚あたり16枚」となります。
矩形の確認
マスク生成の初めのステップで、マスクごとにマスク適用先すべてが収まる矩形を確認します。
void CubismClippingManager_OpenGLES2::CalcClippedDrawTotalBounds(CubismModel& model, CubismClippingContext* clippingContext) { // 被クリッピングマスク(マスクされる描画オブジェクト)の全体の矩形 csmFloat32 clippedDrawTotalMinX = FLT_MAX, clippedDrawTotalMinY = FLT_MAX; csmFloat32 clippedDrawTotalMaxX = FLT_MIN, clippedDrawTotalMaxY = FLT_MIN; // このマスクが実際に必要か判定する // このクリッピングを利用する「描画オブジェクト」がひとつでも使用可能であればマスクを生成する必要がある const csmInt32 clippedDrawCount = clippingContext->_clippedDrawableIndexList->GetSize(); for (csmInt32 clippedDrawableIndex = 0; clippedDrawableIndex < clippedDrawCount; clippedDrawableIndex++) { // マスクを使用する描画オブジェクトの描画される矩形を求める const csmInt32 drawableIndex = (*clippingContext->_clippedDrawableIndexList)[clippedDrawableIndex]; const csmInt32 drawableVertexCount = model.GetDrawableVertexCount(drawableIndex); csmFloat32* drawableVertexes = const_cast<csmFloat32*>(model.GetDrawableVertices(drawableIndex)); csmFloat32 minX = FLT_MAX, minY = FLT_MAX; csmFloat32 maxX = FLT_MIN, maxY = FLT_MIN; csmInt32 loop = drawableVertexCount * Constant::VertexStep; for (csmInt32 pi = Constant::VertexOffset; pi < loop; pi += Constant::VertexStep) { csmFloat32 x = drawableVertexes[pi]; csmFloat32 y = drawableVertexes[pi + 1]; if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; } // if (minX == FLT_MAX) continue; //有効な点がひとつも取れなかったのでスキップする // 全体の矩形に反映 if (minX < clippedDrawTotalMinX) clippedDrawTotalMinX = minX; if (minY < clippedDrawTotalMinY) clippedDrawTotalMinY = minY; if (maxX > clippedDrawTotalMaxX) clippedDrawTotalMaxX = maxX; if (maxY > clippedDrawTotalMaxY) clippedDrawTotalMaxY = maxY; } if (clippedDrawTotalMinX == FLT_MAX) { clippingContext->_allClippedDrawRect->X = 0.0f; clippingContext->_allClippedDrawRect->Y = 0.0f; clippingContext->_allClippedDrawRect->Width = 0.0f; clippingContext->_allClippedDrawRect->Height = 0.0f; clippingContext->_isUsing = false; } else { clippingContext->_isUsing = true; csmFloat32 w = clippedDrawTotalMaxX - clippedDrawTotalMinX; csmFloat32 h = clippedDrawTotalMaxY - clippedDrawTotalMinY; clippingContext->_allClippedDrawRect->X = clippedDrawTotalMinX; clippingContext->_allClippedDrawRect->Y = clippedDrawTotalMinY; clippingContext->_allClippedDrawRect->Width = w; clippingContext->_allClippedDrawRect->Height = h; } }
色分離、分割分離を受けたレイアウト決定
マスクごとに所属するマスクバッファの色チャンネル、分割位置を定めます。
void CubismClippingManager_OpenGLES2::SetupLayoutBounds(csmInt32 usingClipCount) const { // ひとつのRenderTextureを極力いっぱいに使ってマスクをレイアウトする // マスクグループの数が4以下ならRGBA各チャンネルに1つずつマスクを配置し、5以上6以下ならRGBAを2,2,1,1と配置する // RGBAを順番に使っていく。 const csmInt32 div = usingClipCount / ColorChannelCount; //1チャンネルに配置する基本のマスク個数 const csmInt32 mod = usingClipCount % ColorChannelCount; //余り、この番号のチャンネルまでに1つずつ配分する // RGBAそれぞれのチャンネルを用意していく(0:R , 1:G , 2:B, 3:A, ) csmInt32 curClipIndex = 0; //順番に設定していく for (csmInt32 channelNo = 0; channelNo < ColorChannelCount; channelNo++) { // このチャンネルにレイアウトする数 const csmInt32 layoutCount = div + (channelNo < mod ? 1 : 0); // 分割方法を決定する if (layoutCount == 0) { // 何もしない } else if (layoutCount == 1) { //全てをそのまま使う CubismClippingContext* cc = _clippingContextListForMask[curClipIndex++]; cc->_layoutChannelNo = channelNo; cc->_layoutBounds->X = 0.0f; cc->_layoutBounds->Y = 0.0f; cc->_layoutBounds->Width = 1.0f; cc->_layoutBounds->Height = 1.0f; } else if (layoutCount == 2) { for (csmInt32 i = 0; i < layoutCount; i++) { csmInt32 xpos = i % 2; CubismClippingContext* cc = _clippingContextListForMask[curClipIndex++]; cc->_layoutChannelNo = channelNo; cc->_layoutBounds->X = xpos * 0.5f; cc->_layoutBounds->Y = 0.0f; cc->_layoutBounds->Width = 0.5f; cc->_layoutBounds->Height = 1.0f; //UVを2つに分解して使う } } else if (layoutCount <= 4) { //4分割して使う for (csmInt32 i = 0; i < layoutCount; i++) { csmInt32 xpos = i % 2; csmInt32 ypos = i / 2; CubismClippingContext* cc = _clippingContextListForMask[curClipIndex++]; cc->_layoutChannelNo = channelNo; cc->_layoutBounds->X = xpos * 0.5f; cc->_layoutBounds->Y = ypos * 0.5f; cc->_layoutBounds->Width = 0.5f; cc->_layoutBounds->Height = 0.5f; } } else if (layoutCount <= 9) { //9分割して使う for (csmInt32 i = 0; i < layoutCount; i++) { csmInt32 xpos = i % 3; csmInt32 ypos = i / 3; CubismClippingContext* cc = _clippingContextListForMask[curClipIndex++]; cc->_layoutChannelNo = channelNo; cc->_layoutBounds->X = xpos / 3.0f; cc->_layoutBounds->Y = ypos / 3.0f; cc->_layoutBounds->Width = 1.0f / 3.0f; cc->_layoutBounds->Height = 1.0f / 3.0f; } } else { CubismLogError("not supported mask count : %d", layoutCount); } } }
マスク描画、マスク使用のマトリックス生成
描画前に調べた矩形範囲と所属場所に基づいてマスク生成用、マスク使用用の変換マトリックスを用意します。
// --- 実際に1つのマスクを描く --- CubismClippingContext* clipContext = _clippingContextListForMask[clipIndex]; csmRectF* allClippedDrawRect = clipContext->_allClippedDrawRect; //このマスクを使う、全ての描画オブジェクトの論理座標上の囲み矩形 csmRectF* layoutBoundsOnTex01 = clipContext->_layoutBounds; //この中にマスクを収める // モデル座標上の矩形を、適宜マージンを付けて使う csmFloat32 MARGIN = 0.05f; _tmpBoundsOnModel.SetRect(allClippedDrawRect); _tmpBoundsOnModel.Expand(allClippedDrawRect->Width * MARGIN, allClippedDrawRect->Height * MARGIN); //########## 本来は割り当てられた領域の全体を使わず必要最低限のサイズがよい // シェーダ用の計算式を求める。回転を考慮しない場合は以下のとおり // movePeriod' = movePeriod * scaleX + offX [[ movePeriod' = (movePeriod - tmpBoundsOnModel.movePeriod)*scale + layoutBoundsOnTex01.movePeriod ]] csmFloat32 scaleX = layoutBoundsOnTex01->Width / _tmpBoundsOnModel.Width; csmFloat32 scaleY = layoutBoundsOnTex01->Height / _tmpBoundsOnModel.Height; // マスク生成時に使う行列を求める { // シェーダに渡す行列を求める <<<<<<<<<<<<<<<<<<<<<<<< 要最適化(逆順に計算すればシンプルにできる) _tmpMatrix.LoadIdentity(); { // Layout0..1 を -1..1に変換 _tmpMatrix.TranslateRelative(-1.0f, -1.0f); _tmpMatrix.ScaleRelative(2.0f, 2.0f); } { // view to Layout0..1 _tmpMatrix.TranslateRelative(layoutBoundsOnTex01->X, layoutBoundsOnTex01->Y); //new = [translate] _tmpMatrix.ScaleRelative(scaleX, scaleY); //new = [translate][scale] _tmpMatrix.TranslateRelative(-_tmpBoundsOnModel.X, -_tmpBoundsOnModel.Y); //new = [translate][scale][translate] } // tmpMatrixForMask が計算結果 _tmpMatrixForMask.SetMatrix(_tmpMatrix.GetArray()); } //--------- draw時の mask 参照用行列を計算 { // シェーダに渡す行列を求める <<<<<<<<<<<<<<<<<<<<<<<< 要最適化(逆順に計算すればシンプルにできる) _tmpMatrix.LoadIdentity(); { _tmpMatrix.TranslateRelative(layoutBoundsOnTex01->X, layoutBoundsOnTex01->Y); //new = [translate] _tmpMatrix.ScaleRelative(scaleX, scaleY); //new = [translate][scale] _tmpMatrix.TranslateRelative(-_tmpBoundsOnModel.X, -_tmpBoundsOnModel.Y); //new = [translate][scale][translate] } _tmpMatrixForDraw.SetMatrix(_tmpMatrix.GetArray()); } clipContext->_matrixForMask.SetMatrix(_tmpMatrixForMask.GetArray()); clipContext->_matrixForDraw.SetMatrix(_tmpMatrixForDraw.GetArray());
マスクバッファの動的なサイズ変更
GLES2レンダラには、実行時にマスクバッファのサイズを変更するAPIを用意してあります。
現状、マスクバッファのサイズは初期値として256*256(ピクセル)を設定していますが、マスク生成領域を9枚に切るような場合、
85*85(ピクセル)の矩形領域に描画したマスク形状を、更に拡大してクリッピング領域として使用します。
その結果、クリッピング結果のエッジがぼやけたり、滲みのような現象が見られます。
それを解決する方法として、マスクバッファのサイズをプログラム実行時に変更するAPIを用意しています。
例えば、マスクバッファのサイズを256*256 ⇒ 1024*1024とすることで、マスク生成領域を9枚に切るような場合、341*341の矩形領域にマスク形状を描画することができるので、
拡大してクリッピング領域として使用しても、クリッピング結果のエッジのぼやけやにじみを解消することができます。
※マスクバッファのサイズを大きくする ⇒ 処理するピクセルが増えると速度は遅くなるが、描画結果は綺麗になる。
※マスクバッファのサイズを小さくする ⇒ 処理するピクセルが減るので速度は速くなるが、描画結果は汚くなる。
void CubismRenderer_OpenGLES2::SetClippingMaskBufferSize(csmInt32 size) { //FrameBufferのサイズを変更するためにインスタンスを破棄・再作成する CSM_DELETE_SELF(CubismClippingManager_OpenGLES2, _clippingManager); _clippingManager = CSM_NEW CubismClippingManager_OpenGLES2(); _clippingManager->SetClippingMaskBufferSize(size); _clippingManager->Initialize( *GetModel(), GetModel()->GetDrawableCount(), GetModel()->GetDrawableMasks(), GetModel()->GetDrawableMaskCounts() ); }
前処理方式によってパフォーマンスの向上が見込める理由
携帯端末特有の事情として、GPUに対するClear命令やレンダリングターゲット切り替え命令の処理コストが、他の命令よりも高い場合があります。
原則的方式で描画するときには、これら処理コストが高い命令を、マスクが必要になるDrawableの数だけ実行することになります。
しかし、前処理方式の場合では、これらの命令を実行する回数を削減することができるので、スマートフォンなどでのパフォーマンスの向上が見込めます。
実際にその効果を把握するため、レンダリングにおける各処理単位での時間コストを測定してみます。
測定方法として、以下に示すソースコードで確認します。 レイヤーはビルドごと分離して計測します。
また、テスト対象のモデルはHaru一体です。
測定する端末としてはAndroid機を2種類、Windows機を1種類用意しました。
測定はAndroid側でclock_gettime関数、Windows側でQueryPerformanceCounter関数を使用して バッファに結果をキャッシュして平均値を計算するという方針で測定します。
クリッピングマスク生成部(レイヤー1)
void CubismClippingManager_OpenGLES2::SetupClippingContext(CubismModel& model, CubismRenderer_OpenGLES2* renderer) { ・ ・ ・ { // ★描画ターゲットの切り替えの測定 P_TIME1(ProcessingTime cl(s_switch);) // ---------- マスク描画処理 ----------- // マスク用RenderTextureをactiveにセット glBindFramebuffer(GL_FRAMEBUFFER, _maskRenderTexture); } { // ★バッファのクリア(塗りつぶし処理)の測定 P_TIME1(ProcessingTime cl(s_clear);) // マスクをクリアする //(仮仕様) 1が無効(描かれない)領域、0が有効(描かれる)領域。(シェーダで Cd*Csで0に近い値をかけてマスクを作る。1をかけると何も起こらない) glClearColor(1.0f, 1.0f, 1.0f, 1.0f); glClear(GL_COLOR_BUFFER_BIT); } ・ ・ ・ { // ★描画ターゲットの切り替えの測定2 P_TIME1(ProcessingTime cl(s_switch);) // --- 後処理 --- glBindFramebuffer(GL_FRAMEBUFFER, oldFBO); // 描画対象を戻す } ・ ・ ・ }
CubismClippingManager_OpenGLES2::SetupClippingContextでは描画ターゲットの切り替えや塗りつぶしの時間を測定します。
モデル描画全体(レイヤー1、2混在)
void CubismRenderer_OpenGLES2::DoDrawModel() { { // ★マスクバッファ生成全体の測定 P_TIME2(ProcessingTime makemask(s_maskmake);) if (_clippingManager != NULL) { PreDraw(); _clippingManager->SetupClippingContext(*GetModel(), this); } } { // ★描画前処理の測定 P_TIME1(ProcessingTime makemask(s_predraw);) // 上記クリッピング処理内でも一度PreDrawを呼ぶので注意!! PreDraw(); } const csmInt32 drawableCount = GetModel()->GetDrawableCount(); const csmInt32* renderOrder = GetModel()->GetDrawableRenderOrders(); { // ★ソートの時間を測定 P_TIME1(ProcessingTime makemask(s_sort);) // インデックスを描画順でソート for (csmInt32 i = 0; i < drawableCount; ++i) { const csmInt32 order = renderOrder[i]; _sortedDrawableIndexList[order] = i; } } { // ★マスク以外の描画時間測定 P_TIME2(ProcessingTime makemask(s_draw);) // 描画 for (csmInt32 i = 0; i < drawableCount; ++i) { const csmInt32 drawableIndex = _sortedDrawableIndexList[i]; // クリッピングマスクをセットする SetClippingContextBufferForDraw((_clippingManager != NULL) ? (*_clippingManager->GetClippingContextListForDraw())[drawableIndex] : NULL); IsCulling(GetModel()->GetDrawableCulling(drawableIndex) != 0); DrawMesh( GetModel()->GetDrawableTextureIndices(drawableIndex), GetModel()->GetDrawableVertexIndexCount(drawableIndex), GetModel()->GetDrawableVertexCount(drawableIndex), const_cast<csmUint16*>(GetModel()->GetDrawableVertexIndices(drawableIndex)), const_cast<csmFloat32*>(GetModel()->GetDrawableVertices(drawableIndex)), reinterpret_cast<csmFloat32*>(const_cast<Core::csmVector2*>(GetModel()->GetDrawableVertexUvs(drawableIndex))), GetModel()->GetDrawableOpacity(drawableIndex), GetModel()->GetDrawableBlendMode(drawableIndex) ); } } { // ★描画後処理の測定 P_TIME1(ProcessingTime makemask(s_post);) // PostDraw(); } }
描画前、ソート、描画後処理の測定をします。
レイヤーを分けてマスク生成とそれ以外の描画という大枠でも測定します。
メッシュの描画(レイヤー1)
void CubismRenderer_OpenGLES2::DrawMesh(csmInt32 textureNo, csmInt32 indexCount, csmInt32 vertexCount , csmUint16* indexArray, csmFloat32* vertexArray, csmFloat32* uvArray , csmFloat32 opacity, CubismBlendMode colorBlendMode) { ・ ・ ・ { // ★シェーダーへのデータのセット時間を測定 P_TIME1(ProcessingTime sharder(s_sharder);) CubismShader_OpenGLES2::GetInstance()->SetupShaderProgram( this, drawTextureId, vertexCount, vertexArray, uvArray , opacity, colorBlendMode, modelColorRGBA, IsPremultipliedAlpha() , GetMvpMatrix() ); } { // ★描画命令単体の時間測定 P_TIME1(ProcessingTime gldraw(s_gldraw);) // ポリゴンメッシュを描画する glDrawElements(GL_TRIANGLES, indexCount, GL_UNSIGNED_SHORT, indexArray); } // 後処理 glUseProgram(0); SetClippingContextBufferForDraw(NULL); SetClippingContextBufferForMask(NULL); }
CubismRenderer_OpenGLES2::DrawMeshではシェーダーへの設定時間と描画命令単一時間を測定します。
レイヤー3
void LAppModel::Update() { { P_TIME3(ProcessingTime up(s_paramup);) const csmFloat32 deltaTimeSeconds = LAppPal::GetDeltaTime(); _userTimeSeconds += deltaTimeSeconds; ・ ・ ・ // ポーズの設定 if (_pose != NULL) { _pose->UpdateParameters(_model, deltaTimeSeconds); } } { P_TIME3(ProcessingTime ren(s_modelup);) _model->Update(); } }
void LAppModel::Draw(CubismMatrix44& matrix) { P_TIME3(ProcessingTime ren(s_rendering);) matrix.MultiplyByMatrix(_modelMatrix); GetRenderer<Rendering::CubismRenderer_OpenGLES2>()->SetMvpMatrix(&matrix); DoDraw(); }
大枠としてUpdateの流れを
パラメーター計算、モデルのアップデート、レンダリングの3つに分けてみていきます。
結果
Android1 | Android2 | Winpc1 | |
L1clear | 1781.20 | 218.80 | 26.80 |
L1gldraw | 45.47 | 51.63 | 10.58 |
L1sharder | 12.31 | 9.34 | 5.37 |
L1post | 1.50 | 1.00 | 0.10 |
L1switch | 10.70 | 56.30 | 7.80 |
L1predraw | 15.90 | 8.20 | 2.20 |
L1sort | 7.60 | 7.00 | 0.60 |
L2MaskMake | 2686.80 | 1357.60 | 318.50 |
L2draw | 4004.10 | 4013.20 | 1217.00 |
L3paramupdate | 392.00 | 375.40 | 89.70 |
L3modelupdate | 1357.50 | 1410.90 | 1070.40 |
L3rendering | 6715.70 | 5233.70 | 1892.00 |
上の表は先に示した部分の実行時間です。
携帯端末はClearのコストが高かったり、レンダリングターゲットの切り替えが他の命令に比べて重たいことがわかります。
この重い命令が原則的な方法で描画するときにはマスクが必要になるDrawableの数だけ走ります。
この計算が一回で済むためスマートフォンなどでのパフォーマンスの向上が見込めます。
マスクの処理を高精細な方式に切り替える
前述の通り、描画の度にマスクを生成する方式だと、低スペック端末ではパフォーマンスに影響があります。
しかし、最終的に動画として出力する場合のように、ランタイムでのパフォーマンスよりも画面の品質の方を重視する場合はこちらの方式のほうが適しています。
2018/12/20以降のSDKでは、マスクの処理を高精細な方式に切り替える事ができます。
高精細な方式に切り替えるには、以下のAPIにtrueを渡します。
CubismRenderer::UseHighPrecisionMask()