ガチャメモリリーク 実機解析ツール検討
Description
Audio メモリリークの解析では、実機解析時 Safari スニペットに 以下 コマンドを実行することで、個別ファイルレベルでのメモリリークを特定できた Object.keys(window.__pixiSound._sounds)
これの倣って、画像のメモリリークの特定をしやすいように同様の解析ツールを作り、ガチャの特定の演出論理の問題点の改修に努める
Comments (18)
メモリリーク/GPU圧迫の容疑者と所見 をまとめます。
1. 最有力: useImageCache の data URL 100件キャッシュ
useImageCache.ts:6-32
- グローバル
Map<string, string>に base64 data URL を最大100件保持。data URL は元ファイル比 ~1.3倍の文字列 → JS ヒープ常駐。 - それを
<img src="data:...">で描画 → iOS WebKit が デコード済みビットマップ を別途内部キャッシュ。1枚 2MB の webp が 1024×1024 RGBA だと展開後 4MB+。100枚 = JSヒープ + decoded bitmap で合計数百MB級になりうる。 clearImageCacheはあるが演出のタイミングでは呼ばれていない(ガチャ画面遷移時のフックなし)。- 改善案: data URL →
Blob+URL.createObjectURLに変更し、cache eviction 時にrevokeObjectURL。decoded bitmap の重複も解消できる。
2. 演出 DOM 要素数が異常に多い
CharacterObtainedOverlay.tsx:209,224,261
- 1キャラ表示ごとに:
- 回転光線
BURST_RAY_COUNT=55 - 集中線
FOCUS_STREAK_COUNT=84× 3層(back/front/前面)+FRONT_FOCUS_STREAK_COUNT=26= 計 278本 - 粒系 sparkles 16+30 / suck 44
- 合計 ~420 個の DOM が
animation:付きで同時稼働
- 回転光線
- 各要素が
filter: blur(...)/mix-blend-mode: screen/radial-gradient/mask-imageを併用 → iOS WebKit でほぼ全てが GPU コンポジションレイヤーに昇格。 GachaAnimation.tsx:68,85key={revealIndex}で 完全再マウント → 10連は 420要素 × 10回の作成/破棄。古いレイヤーの GPU テクスチャ解放が間に合わないと WebGL context lost を誘発しうる。
3. 軽微な蓄積
useLayeredImagePreload.ts:15のloadedUrlsSet は上限なし(ただし文字列のみで軽い)。CharacterObtainedOverlay.tsx:517-522new Image()の cleanup がonload = nullのみでsrc = ""していない — iOS で decoded bitmap が短時間残る。
まず観測を入れて仮説を裏取りしたい
A. useImageCache の状態を公開
// useImageCache.ts 末尾に追加
if (typeof window !== "undefined") {
(window as any).__imageCache = {
map: imageCache,
sizeBytes: () => [...imageCache.values()].reduce((s, v) => s + v.length, 0),
};
}
これで window.__imageCache.sizeBytes() がガチャ前後でどれだけ増えるか / 100件上限に張り付いているかが見えると思われる
ログ差し込み
// 画像メモリリーク調査用に imageCache を公開する。INS-862 で導入。
// data URL を文字列として保持しているので、件数だけでなく累計バイト数を見たい。
// 使用例:
// window.__imageCache.size() // 件数(最大100)
// window.__imageCache.sizeBytes() // data URL 文字列の累計バイト数
// (window.__imageCache.sizeBytes() / 1024 / 1024).toFixed(2) // MB 表記
// window.__imageCache.keys() // キャッシュ中のパス一覧
// window.__imageCache.top(5) // サイズ上位 N 件 [{key, bytes}]
// // ガチャ前後の差分:
// const before = window.__imageCache.sizeBytes();
// // …10連ガチャ…
// window.__imageCache.sizeBytes() - before; // 増分(byte)
if (typeof window !== "undefined") {
(
window as unknown as {
__imageCache?: {
size: () => number;
sizeBytes: () => number;
keys: () => string[];
top: (n?: number) => { key: string; bytes: number }[];
clear: () => void;
};
}
).__imageCache = {
size: () => imageCache.size,
sizeBytes: () =>
[...imageCache.values()].reduce((sum, v) => sum + v.length, 0),
keys: () => [...imageCache.keys()],
top: (n = 10) =>
[...imageCache.entries()]
.map(([key, v]) => ({ key, bytes: v.length }))
.sort((a, b) => b.bytes - a.bytes)
.slice(0, n),
clear: clearImageCache,
};
}
データ取得内容
// 1) 起動直後(ログイン後ホーム)
window.__imageCache.size()
window.__imageCache.sizeBytes()
window.__imageCache.top(10)
// 2) ガチャ画面に遷移直後
window.__imageCache.size()
window.__imageCache.sizeBytes()
// 3) 10連ガチャの直前にスナップショット
const snap1 = {
count: window.__imageCache.size(),
bytes: window.__imageCache.sizeBytes(),
keys: new Set(window.__imageCache.keys()),
};
// 4) 10連ガチャ演出を最後まで再生し終わった直後
const snap2 = {
count: window.__imageCache.size(),
bytes: window.__imageCache.sizeBytes(),
keys: window.__imageCache.keys(),
};
const delta = {
countDelta: snap2.count - snap1.count,
bytesDelta: snap2.bytes - snap1.bytes,
bytesMBDelta: ((snap2.bytes - snap1.bytes) / 1024 / 1024).toFixed(2) + " MB",
newKeys: snap2.keys.filter(k => !snap1.keys.has(k)),
};
console.table(delta);
console.table(window.__imageCache.top(10));
// 5) 10連ガチャを 3回繰り返した直後(蓄積を見る)
window.__imageCache.size() // 100張り付きならLRU evict 発生 = 容量超過の証拠
window.__imageCache.sizeBytes()
ホーム画面
window.__imageCache.size()
< 0
window.__imageCache.sizeBytes()
< 0
window.__imageCache.top(10)
< [] (0)
[preload] unloadBundle done: "home" (released 0 images, 9 sounds)
[Log] [cache-metrics] preload "gacha": 7件 1514ms (cumulative cacheHit=0 network=0 0KB) (index-P4Wb1vOZ.js, line 16)
ガチャ画面に移動後
window.__imageCache.size()
< 0
window.__imageCache.sizeBytes()
< 0
"想戦の結晶" がでてしばらくして
document.querySelectorAll('[style*="animation"]').length
< 190
window.__pixiApp?.renderer?.texture.managedTextures.length
< undefined
window.__pixiAssets.cache._cache.size
< 6
セリナの勲章
document.querySelectorAll('[style*="animation"]').length
< 191
青ヘパイス
document.querySelectorAll('[style*="animation"]').length
< 240
WebGL: context lost.
ここでBGM停止
App タップがきかない
skip がなんとかできた。
console.table(delta);
[Log] (インデックス)
0
1
2
3
4
5
6
7
newKeys
"items/battle_memory_fragment.webp"
"items/peridot.webp"
"characters/selina/icon.2a427cd9d2.webp"
"equipments/accessory/accessory-powerring.webp"
"items/adamantite.webp"
"items/blue_hephice.webp"
"equipments/accessory/accessory-ring-of-rain.webp"
"items/magic_ore.webp"
console.table(window.__imageCache.top(10));
0
"items/blue_hephice.webp"
147459
1
"items/magic_ore.webp"
73879
2
"items/peridot.webp"
68207
3
"items/adamantite.webp"
58231
4
"characters/selina/icon.2a427cd9d2.webp"
20671
5
"equipments/accessory/accessory-ring-of-rain.webp"
10031
6
"equipments/accessory/accessory-powerring.webp"
8807
7
"items/battle_memory_fragment.webp"
3415
10連ガチャ終了後サマリー表示時点
window.__imageCache.size()
< 8
window.__imageCache.sizeBytes()
< 390700
document.querySelectorAll('[style*="animation"]').length
< 195
document.querySelectorAll('[style*="animation"]').length
< 195
window.__pixiApp?.renderer?.texture.managedTextures.length
< undefined
window.__pixiAssets.cache._cache.size
< 6
つづけて20連目開始時点 女神の3選択肢 で以下増加が確認できた。
document.querySelectorAll('[style*="animation"]').length
< 350
window.__pixiApp?.renderer?.texture.managedTextures.length
< undefined
window.__pixiAssets.cache._cache.size
< 6
ジャジャム 新キャラ登場
document.querySelectorAll('[style*="animation"]').length
< 843
window.__pixiApp?.renderer?.texture.managedTextures.length
< undefined
window.__pixiAssets.cache._cache.size
< 6
(1) body 直下の要素ごとに、アニメ要素数をカウント — portal root 累積を見る
console.table(
[...document.body.children].map(c => ({
tag: c.tagName,
cls: (c.className || "").toString().slice(0, 80),
children: c.children.length,
anim: c.querySelectorAll('[style*="animation"]').length,
}))
);
0
"DIV"
""
3
195
1
"DIV"
"fixed inset-0 z-[99999] pointer-events-auto"
14
648
// (2) アニメ要素のクラス別件数 — どの演出パーツが累積してるか
const counts = {};
document.querySelectorAll('[style*="animation"]').forEach(e => {
const k = (e.className || "(no-class)").toString().slice(0, 60);
counts[k] = (counts[k] || 0) + 1;
});
console.table(
Object.entries(counts).sort((a,b) => b[1]-a[1]).slice(0, 20)
.map(([cls, n]) => ({ cls, n }))
);
0
"absolute"
490
1
"absolute rounded-full"
157
2
"absolute left-1/2 top-1/2"
120
3
"w-full h-full"
30
4
"absolute left-1/2 top-1/2 rounded-full"
20
5
"relative aspect-square h-[calc(20.37_*_var(--gvh))]"
10
6
"absolute rounded-full pointer-events-none z-[15]"
2
7
"h-full w-auto object-contain drop-shadow-[calc(0.8*var(--gvh"
2
8
"absolute inset-0 overflow-hidden pointer-events-none"
1
9
"absolute inset-0"
1
10
"h-[160%] w-[65%] absolute left-[0%] top-[0%] z-0"
1
11
"absolute inset-0 flex items-start justify-center drop-shadow"
1
12
"absolute z-[50] rounded-full pointer-events-none"
1
13
"absolute inset-0 z-[50] bg-white"
1
14
"absolute inset-x-0 bottom-0 pointer-events-none z-[47]"
1
15
"absolute inset-x-0 top-0 pointer-events-none z-[47]"
1
16
"absolute z-30 pointer-events-none flex flex-col items-center"
1
17
"w-[13.125%] h-full flex flex-col items-center justify-center"
1
18
"flex-1 h-full flex items-center justify-start whitespace-now"
1
19
"w-full h-[7.95%] flex items-center justify-center font-mplus"
1
// (3) ガチャ系コンテナの生存数
({
gachaAnimation: document.querySelectorAll('[data-testid="gacha-animation"]').length,
fixedFull: document.querySelectorAll('.fixed.inset-0').length,
highZ: [...document.querySelectorAll('*')].filter(e => {
const z = parseInt(getComputedStyle(e).zIndex, 10);
return !isNaN(z) && z >= 1000;
}).length,
})
{gachaAnimation: 1, fixedFull: 10, highZ: 8} = $12
// (4) DOM 総数と body 直下の数(基準値)
({
totalDom: document.querySelectorAll('*').length,
bodyChildren: document.body.children.length,
imgTags: document.querySelectorAll('img').length,
})
{totalDom: 1392, bodyChildren: 2, imgTags: 75} = $13
// (5) 念のため imageCache 現況
({
imageCacheCount: window.__imageCache.size(),
imageCacheMB: (window.__imageCache.sizeBytes()/1024/1024).toFixed(2),
})
< {imageCacheCount: 8, imageCacheMB: "0.37"}
上記により、、
body 直下に <div class="fixed inset-0 z-[99999] pointer-events-auto"> が 1つあり、その 直接子が 14個 蓄積、内部にアニメ要素 648個。これが演出ごとに unmount されず積み上がっている portal/overlay container となっている模様
createPortal の宛先と、z-[99999] を作っている場所を特定を試みる
fixed inset-0 z-[99999] の div は Character/ItemObtainedOverlay の portal 出力で確定か(CharacterObtainedOverlay.tsx:728, ItemObtainedOverlay.tsx:567)。
body直下に1個だけ=portal自体の累積は無し。GachaMainAnimation は z-[10000] なので別物。
ただ 1オーバーレイの理論アニメ要素数(~340個)に対して 648個 あるので、2つの Overlay が同時に mount されている疑い が残ります。あと #root 側の 195 anim も誰かが裏で動いている疑い。
ジャジャム 新キャラ召喚時 の 詳細データをとる
// (A) 同テストID オーバーレイの生存数 — 1個より多ければ leak 確定
({
characterObtained: document.querySelectorAll('[data-testid="character-obtained-overlay"]').length,
itemObtained: document.querySelectorAll('[data-testid="item-obtained-overlay"]').length,
gachaMainAnim: document.querySelectorAll('[data-testid="gacha-main-animation"]').length,
})
{characterObtained: 1, itemObtained: 0, gachaMainAnim: 0}
// (B) portal div(z-[99999]) 直下の14個の中身を出す
{
const portal = [...document.body.children]
.find(c => c.className.includes("z-[99999]"));
console.table([...portal.children].map((c, i) => ({
i,
tag: c.tagName,
cls: (c.className || "").toString().slice(0, 70),
anim: c.querySelectorAll('[style*="animation"]').length,
children: c.children.length,
})));
}
SyntaxError: Unexpected identifier 'portal'. Expected a ':' following the property name 'const'.
// (C) #root の 195 anim はどこから? — 直下クラス別
{
const root = document.body.children[0];
console.table([...root.querySelectorAll('[style*="animation"]')]
.reduce((acc, e) => {
// 直近の data-testid 付き祖先で集計
let p = e;
while (p && !p.dataset?.testid && p !== root) p = p.parentElement;
const k = p?.dataset?.testid || "(no-testid-ancestor)";
acc[k] = (acc[k] || 0) + 1;
return acc;
}, {}));
}
SyntaxError: Unexpected identifier 'root'. Expected a ':' following the property name 'const'.
// (D) 念のため: 全 fixed inset-0 を列挙してどれが何か特定
console.table([...document.querySelectorAll('.fixed.inset-0')].map((e, i) => ({
i,
cls: (e.className || "").toString().slice(0, 70),
testid: e.dataset?.testid || "",
parentTag: e.parentElement?.tagName,
parentIsBody: e.parentElement === document.body,
anim: e.querySelectorAll('[style*="animation"]').length,
})));
(インデックス)
i
cls
testid
parentTag
parentIsBody
anim
9
9
"fixed inset-0 z-[99999] pointer-events-auto"
"character-obtained-overlay"
"BODY"
true
648
2
2
"fixed inset-0 transition-opacity duration-200 ease-in-out"
""
"DIV"
false
195
0
0
"fixed inset-0"
""
"DIV"
false
195
5
5
"fixed inset-0 z-9999 w-full h-full items-center justify-center flex po"
""
"DIV"
false
180
4
4
"fixed inset-0 z-[9998] pointer-events-none"
""
"DIV"
false
15
8
8
"fixed inset-0 w-full h-full pointer-events-none z-[2000000]"
""
"DIV"
false
0
7
7
"fixed inset-0 z-[10000] overflow-hidden"
"gacha-animation"
"DIV"
false
0
6
6
"bg-cover bg-center bg-no-repeat fixed inset-0 pointer-events-none"
""
"DIV"
false
0
3
3
"fixed inset-0 z-[9998] bg-cover bg-center bg-no-repeat"
""
"DIV"
false
0
「1演出の単体重量が極大」
数値で確定したこと
| 観測 | 数値 | 意味 |
|---|---|---|
characterObtained | 1 | Overlay 多重 mount なし |
imageCache | 8件 / 0.37MB | data URL 累積リークなし |
| body 直下 | 2要素 | portal 累積なし |
| 1 CharacterObtainedOverlay 内 | 648 anim | これが GPU 主犯 |
| baseline (gacha scene + OverlayUI) | 195 anim | 常駐分 |
| ピーク同時稼働 | 843 anim | (新キャラ登場時) |
メモリリークがあってもなくても、いずれにしても、1演出が単発でこれだけ重い → iOS WebKit の GPU レイヤープールが枯渇 → WebGL context lost、という構造はある模様
10連ガチャするたびに error ”WebGL context lost" は、出てBGMが停止したりするので、少なくとも、10連ガチャのうち単発ごとの演出の重さは軽減する必要あり。
推定
CharacterObtainedOverlay 単体 648 アニメ要素の内訳推定
CharacterObtainedOverlay.tsx の定数から:
- focusStreaks × 3層 = 84+84+26 = 194
- BURST_RAY = 55
- suckParticles = 44, crossSparkles = 30, risingSparkles = 16
- 計 ~340個の generator 由来 + 各要素が複数の animation/blur レイヤーを持つので、実 DOM で 648 になる
- 大半が
filter: blur()/mix-blend-mode: screen/mask-image併用 → 全てが iOS で GPU レイヤー昇格
WebGL context lost が「青ヘパイス 240要素」で起きた理由
その直前の重たい演出(セリナ勲章=191、想戦結晶=190)の GPU テクスチャ解放が WebKit 側で間に合わず、累積した GPU メモリ圧 + 新しいオーバーレイ生成 = WebKit が context drop で防衛、という挙動。JS ヒープではなく GPU side のリーク的滞留を疑う
一旦、ガチャ演出修正前のデータ
【昨日26/6/17 リモート develop ソース時点(before)】
・1キャラ獲得演出: DOM 648 要素 (filter:blur / mix-blend / mask 多用)
・1ガチャ画面 baseline: 195 要素
・ピーク同時稼働: 843 要素 (GPU レイヤー化)
・imageCache (JSヒープ): 0.37MB (リークなし、シロ)
・Pixi WebGL textures: 6 (シロ)
・portal/DOM累積: なし (リークではない)
・症状: 10連 2回目 ~ ジャジャム登場で WebGL context lost / BGM停止 / 操作不能
・原因: 1演出あたりの GPU レイヤー数が iOS WKWebView の許容を越える
ただ、体感上 実機での挙動をみると、上記意外にも負荷はあり、差し込んだログでは、検知できていないメモリリークは疑う余地あり。
ここまでのまとめです
1. 調査背景
- ブランチ:
INS-862_Gacha_memory_leak_ - 症状(ユーザー報告):
- 10連ガチャを繰り返すとWebGL: context lost が発生
- 同時に BGM 停止、操作不能
- 上長が部分的に対策した版でも ガチャとホーム5往復でApp停止 という蓄積系の障害報告あり
- 仮説: 「演出単発の高負荷」と「累積するメモリリーク」が両方起きている
2. 今回セッションで追加した観測ポイント
window 上に以下のフックを追加(INS-862 タグ):
| グローバル | ファイル | 観測対象 |
|---|---|---|
window.__pixiSound | Home.tsx:84 | (既存・INS-817) @pixi/sound の _sounds Map |
window.__pixiAssets | Home.tsx | PIXI Assets.cache._cache |
window.__pixiApp | PixiStage.tsx | 現 mount 中の Application(renderer / managedTextures) |
window.__imageCache | useImageCache.ts | size() / sizeBytes() / keys() / top(n) / clear() |
3. 実測データ(10連 × 2 ガチャ中)
3-1. 観測値の推移
| タイミング | __imageCache size / bytes | [style*="animation"] 数 | __pixiAssets.cache | __pixiApp |
|---|---|---|---|---|
| ホーム画面 | 0 / 0B | — | 0 | undefined |
| ガチャ画面遷移直後 | 0 / 0B | — | 6 | undefined |
| 1連目「想戦の結晶」 | — | 190 | 6 | undefined |
| 1連目「セリナ勲章」 | — | 191 | 6 | undefined |
| 1連目「青ヘパイス」 | — | 240 | 6 | undefined |
↑ ここで WebGL: context lost / BGM停止 / タップ不能 | ||||
| 10連サマリー | 8 / 390KB | 195 | 6 | undefined |
| 2回目10連「女神3選択肢」 | 8 / 390KB | 350 | 6 | undefined |
| 2回目10連「ジャジャム新キャラ登場」 | 8 / 390KB | 843 | 6 | undefined |
3-2. ピーク時点(ジャジャム登場)の DOM 構成
document.body.children.length= 2<div>(= #root) … 195 anim 要素<div class="fixed inset-0 z-[99999] pointer-events-auto" data-testid="character-obtained-overlay">… 648 anim 要素
document.querySelectorAll('[data-testid="character-obtained-overlay"]').length= 1document.querySelectorAll('[data-testid="item-obtained-overlay"]').length= 0document.querySelectorAll('[data-testid="gacha-main-animation"]').length= 0document.querySelectorAll('img').length= 75document.querySelectorAll('*').length= 1392
3-3. アニメ要素のクラス分布(ピーク時)
| クラス | 件数 |
|---|---|
absolute | 490 |
absolute rounded-full | 157 |
absolute left-1/2 top-1/2 | 120 |
w-full h-full | 30 |
absolute left-1/2 top-1/2 rounded-full | 20 |
| (以下少数) | … |
4. 観測できた範囲で確定した事実
useImageCacheの data URL キャッシュは 8件 / 0.37MB に留まり、本セッション計測範囲では肥大していない。<img>タグは ピーク時 75 個。body直下の DOM 要素数は 常時2個(createPortalの portal node 累積は本セッション計測範囲では発生していない)。[data-testid="character-obtained-overlay"]は 同時に1個 しか存在していない(CharacterObtainedOverlay の多重 mount は本セッション計測範囲では発生していない)。WebGL: context lostは「青ヘパイス出現時 (anim 要素 240)」という比較的軽い場面で発生しており、その後の「ジャジャム登場 (anim 843)」のような重い場面まで持たずに既に GPU リソースが枯渇していた。CharacterObtainedOverlay単体で anim 要素 648 個(コード上の generator 数 ≒ 340 + 1要素あたり複数アニメ層)。大半がfilter: blur()/mix-blend-mode: screen/mask-imageを併用。- ガチャ画面の常駐 baseline(OverlayUI 下の scene UI)は 195 anim 要素。
- ガチャ演出は PixiStage を使っていない(
GachaAnimation.tsx/CharacterObtainedOverlay.tsx/ItemObtainedOverlay.tsxは DOM<img>+ CSS animation 構成)。__pixiAppが undefined なのはこのため。
5. 本セッションの観測フックでは まだ検知できていない 領域
以下は「リークが無い」ことを示せていない。引き続き要調査:
- iOS WKWebView 内部のデコード済み bitmap キャッシュ(JS から観測不可)
- ガチャ演出中の GPU テクスチャ滞留(ガチャ画面に PixiStage が無いため
__pixiApp.renderer.texture.managedTexturesで観測できない) useLayeredImagePreload.ts:15のloadedUrlsSet(上限なし、未公開)preload-image.tsのpreloadedUrls/preloadedCdnPathsSet(上限なし、未公開)useEffect内のsetTimeout/setInterval/requestAnimationFrameの cleanup 漏れaddEventListenerの remove 漏れ@pixi/soundの AudioBuffer 滞留(window.__pixiSound._soundsは公開済みだが今回未計測)- React 内部のクロージャ参照(Jotai atom / React Query キャッシュなど)
new Image()後にimg.src = ""していない箇所(CharacterObtainedOverlay.tsx:517等)- ホームとガチャの行き来を5往復くらいで停止という蓄積系障害は 本セッションのスナップショット計測では再現も計測もしていない。20連目の 3つめあたりでジャジャムが登場してきた時点でのわかっている問題のまとめ
6. 現時点の仮説
- 仮説A: 1演出の DOM 重量(CharacterObtainedOverlay 単体 648 anim + blur/blend/mask 多用)が iOS WKWebView の GPU レイヤープール上限を圧迫し、複数演出を跨いだ GPU テクスチャ解放遅延と相まって
WebGL: context lostを誘発している。 - 仮説B: 上記とは独立に、JS ヒープまたは WebKit 内部キャッシュに累積するリークが存在し、ガチャとホームの5往復の蓄積で破綻する。今回の観測フックでは捕捉できていない。