09-vfx-pooling.md · Cập nhật lần cuối: 2026-09-05 08:45 +07 · sha256 nguồn 5c86f4c2a17109 — VFX & pooling
Nguồn framework:
docs/framework/03-particles-vfx.mdtoàn bộ;docs/framework/02-combat-projectiles.md§3.1 (đường lấy đạn từ pool). Cấp bằng chứng: L1 ANALYSIS — dựng lại clean-room từ disasm + metadata + quan sát runtime; không phải source gốc. Mỗi dòng bên dưới mang nhãn bằng chứng riêng.
Mục tiêu
Muốn hiệu ứng liên tục (mỗi viên đạn trúng, mỗi cây đổ, mỗi cục quặng vỡ) mà không để GC giết frame-rate trên điện thoại, thì học cách CatGunner ship 7 718 ParticleSystem trong một scene và vẫn chạy: hai kiểu pool cho hai bài toán khác nhau — pool ở mức hạt cho thứ không cần state, pool ở mức GameObject chỉ cho thứ cần collider/state — cộng một bộ throttle ba tầng. Với artist: "độ hoành tráng" là một số nguyên count, không phải một prefab khác.
Game gốc làm gì
| Điều game làm | Con số / hằng số | Mức bằng chứng | Nguồn |
|---|---|---|---|
Scene Main.unity chứa 7 718 ParticleSystem và 7 718 ParticleSystemRenderer |
7 718 | scene-census | [report: asset_lane.md] §4a (UnityPy 150 958 object) |
Có hai hệ pool độc lập: Particle_Polling (pool hạt) và Polling_manager (pool GameObject) |
2 | disasm-proven | Particle_Polling RVA 0x2ABB3AC; Polling_manager dump.cs:589369 |
Particle_Polling đăng ký 3 slot static theo Particle_index: 0 → instance, 1 → instance_dust, 2 → instance_slime |
3 kênh | disasm-proven | Awake RVA 0x2ABB3AC |
Particle_Polling.Spawn(Vector3 Pos, int Count): EmitParams { position = Pos, applyShapeToPosition = true } rồi Emit(emitParams, Count) trên mọi system trong _particleSystem[] |
— | disasm-proven | RVA 0x2ABB458 |
Polling_manager chỉ pool 2 loại: Objects_Type { Bullet = 0, Bullet_FX = 1 } |
2 | metadata-proven | dump.cs:589342, :589369 |
Object_Polling(type) và Object_Polling_Smart(type) đều chạy 207× trong 40 s = đúng số phát bắn |
207 | metadata-proven + runtime-observed | [runtime trace: runtime_ranking.json] |
27 instance Bullet trong scene ⇒ pool cố định, không Instantiate mỗi phát |
27 | scene-census | [index: scene_census.json] |
FXThrottle là static class: MIN_INTERVAL = 0.05, MIN_DIST_SQ = 2.25 (= 1.5²), MAX_PER_FRAME = 3 |
0.05 / 2.25 / 3 | metadata-proven | dump.cs:596485 |
FXThrottle.CanSpawn(int gunNum, Vector3 pos) là cổng bắt buộc trước mọi FX trúng đích |
— | disasm-proven | Bullet.SpawnFX RVA 0x2B086A8 |
Bullet cache GetComponent<Bullet_FX> trong static Dictionary<GameObject, Bullet_FX> _fxCache |
— | disasm-proven | Bullet.cs field static 0x0 |
Bullet.SpawnFX_Once — chỉ một FX cho toàn bộ vòng đời đạn xuyên/laser |
— | disasm-proven | [evidence codegen: Bullet.cs] |
Bullet_FX.Skin_Reload(int) bỏ qua đổi skin nếu _lastGunNum == GunNum, chỉ Clear() + Play() |
.ctor đặt _lastGunNum = -1 |
disasm-proven | RVA 0x2B096FC |
Bullet_Mesh (1 215 instance) bật/tắt mảng ParticleSystem[]: Active() = Play() mọi phần tử; Stop() = Stop() + Clear() |
Stop 387×, Active 207× |
disasm-proven + runtime-observed | RVA 0x2B07A70 / 0x2B079EC |
Bụi khi phá quặng: Particle_Polling.instance_dust.Spawn(DustSpawn_trans.position, 5) |
5 | disasm-proven | Ore.Die_Reload RVA 0x2AF76E4 |
Cây đổ: Particle_Polling.instance.Spawn(MoneySpawn_trans.position, is_Boss ? 15 : 5) |
15 / 5 | disasm-proven | Tree.Die_Reload RVA 0x2B43904 |
Cây speed-run: Particle_Polling.instance.Spawn(pos, 5) |
5 | disasm-proven | Tree_SpeedRun.Die_Reload RVA 0x2B434A0 |
Destroy component tự SetActive(false) sau Destroy_Time giây bằng coroutine, huỷ coroutine cũ ở OnEnable/OnDisable |
OnEnable 208×, Disable 208×, OnDisable 203× |
disasm-proven + runtime-observed | RVA 0x2AC65A0… |
Bullet dùng Invoke("Deactivate", _deactivateDelay) (mặc định 1f), không coroutine |
1 s | disasm-proven | doc 03 §3.4; SpawnEx RVA 0x2B07AD0 |
ParticleImage (hạt trên UI) là package thật: UI Particle Image, Asset Store 235001 |
— | package-source | quy tắc dự án (nội bộ) R2; [report: project_lane.md] §4 |
| Không có Addressables / AssetBundle — mọi prefab hiệu ứng nằm sẵn trong scene | — | scene-census (phủ định) | [report: asset_lane.md] §3 |
Công thức / pseudocode
Pool ở mức HẠT — L1 ANALYSIS, Particle_Polling.Spawn RVA 0x2ABB458:
public void Spawn(Vector3 Pos, int Count)
{
ParticleSystem.EmitParams emitParams = default(ParticleSystem.EmitParams);
emitParams.position = Pos;
emitParams.applyShapeToPosition = true; // shape module vẫn áp tương đối so với Pos -> có tán xạ
for (int i = 0; i < _particleSystem.Length; i++)
_particleSystem[i].Emit(emitParams, Count);
}
// Không GameObject nào được tạo/bật/tắt. Chi phí mỗi "spawn" gần bằng 0.
// 3 kênh static: instance (vàng rơi) / instance_dust (bụi đá) / instance_slime (?)
Pool ở mức GAMEOBJECT — chữ ký metadata-proven; thân Object_Polling CHƯA dựng lại:
Polling_manager.instance.Object_Polling(Objects_Type.Bullet) -> Bullet (có collider, Rigidbody, _hitIds)
Polling_manager.instance.Object_Polling(Objects_Type.Bullet_FX) -> Bullet_FX (state skin theo gun)
// Bullet.SpawnFX có `if (fxObj == null) return;` => pool TRẢ NULL là khả năng thật
Throttle ba tầng — hằng số metadata-proven dump.cs:596485; thân CanSpawn (RVA 0x2B09320) CHƯA dựng lại:
private static readonly Record[] _last; // ghi nhớ lần spawn cuối theo gunNum
private const float MIN_INTERVAL = 0.05f; // (1) thời gian: tối đa 20 FX/giây cùng một nguồn
private const float MIN_DIST_SQ = 2.25f; // (2) không gian: = 1.5^2, quá gần lần trước thì bỏ
private static int _frameCount;
private static int _spawnThisFrame;
private const int MAX_PER_FRAME = 3; // (3) frame: trần cứng — cứu frame-rate khi AoE trúng 20 quái
public static bool CanSpawn(int gunNum, Vector3 pos) { … } // thứ tự kiểm tra: UNKNOWN
Tránh GetComponent trên hot path — L1 ANALYSIS, Bullet.SpawnFX RVA 0x2B086A8:
GameObject fxObj = Polling_manager.instance.Object_Polling(Objects_Type.Bullet_FX);
if (fxObj == null) return;
if (!_fxCache.TryGetValue(fxObj, out Bullet_FX fx))
{
fx = fxObj.GetComponent<Bullet_FX>();
_fxCache[fxObj] = fx; // pool hữu hạn => cache bão hoà sau vài frame
}
if (fx != null) fx.Spawn(gunNum, pos);
Đường nhanh "không có gì đổi" — L1 ANALYSIS, Bullet_FX.Skin_Reload RVA 0x2B096FC:
if (_lastGunNum == GunNum) { particle?.Clear(); particle?.Play(); return; } // ~100 % thời gian
particle?.Stop(); particle.gameObject.SetActive(false);
particle = Skin_particles[GunNum];
particle.gameObject.SetActive(true);
particle.Clear(); particle.Play();
_lastGunNum = GunNum;
Tự trả về pool bằng hẹn giờ — L1 ANALYSIS, Destroy RVA 0x2AC65A0:
public void Active() { gameObject.SetActive(true); restart coroutine Disable(); }
private void OnEnable() { restart coroutine Disable(); }
private void OnDisable() { if (cor != null) { StopCoroutine(cor); gameObject.SetActive(false); } }
private IEnumerator Disable() { yield return new WaitForSeconds(Destroy_Time); gameObject.SetActive(false); }
// Không phụ thuộc callback từ gameplay -> KHÔNG THỂ rò rỉ
Gợi ý framework (không phải bằng chứng SOT):
IParticleBurstPool : Emit(channel, position, count) // không GameObject, chi phí ~0, kênh là enum
IObjectPool<T> : Rent(type) / tự trả về theo hẹn giờ // cho thứ có collider/state
Throttle (thời gian + khoảng cách + trần mỗi frame) nằm sẵn trong IParticleBurstPool, tham số cấu hình được
Bẫy đã gặp
- Pool GameObject cho mọi hiệu ứng. Trả giá bằng hàng nghìn
SetActivemỗi giây. SOT chỉ pool GameObject cho 2 loại cần state (Bullet,Bullet_FX); phần còn lại làEmit()vào system có sẵn (dump.cs:589342). - Lookup kênh hiệu ứng theo chuỗi / dictionary trên hot path. SOT dùng 3 biến static đánh chỉ số bằng
Particle_index— không có lookup theo tên. - Bỏ trần mỗi frame. Hai tiêu chí thời gian + khoảng cách chỉ giảm rác ở trạng thái bình thường;
MAX_PER_FRAME = 3là thứ duy nhất cứu kịch bản xấu nhất (nổ AoE trúng nhiều quái cùng lúc). - Gọi
GetComponent<Bullet_FX>mỗi phát bắn. SOT cache theo instance đã pool trongstatic Dictionary<GameObject, Bullet_FX>; vì pool hữu hạn, cache bão hoà và không bao giờ gọiGetComponentnữa. - Đổi skin FX bằng
SetActivemỗi phát. Người chơi hiếm khi đổi súng; SOT so_lastGunNumvà thoát sớm chỉ vớiClear() + Play(). - Object trả về pool bằng callback từ logic gameplay. Sẽ có ngày callback không được gọi. SOT dùng hẹn giờ (
Destroycoroutine,Invoke("Deactivate")). - Giả định pool luôn trả object.
Bullet.SpawnFXkiểmfxObj == null— và nay biết pool trả null chỉ khi mọi ô còn sống (hai lượt: lấy ô inactive, rồi cướp ô đã chết và huỷ hẹnDeactivatecủa nó — G363); bên bắn hoàn lại cooldown. - Object lấy từ pool mang state cũ.
ResetState()củaBulletphải xoá_hitIds,_pierceCount, cờ;Bounce_Custom.OnEnablereset scale (xem 07-juice-game-feel.md). Đây là hợp đồng pooling quan trọng nhất. - Làm prefab riêng cho "chết boss". SOT chỉ đổi
count:is_Boss ? 15 : 5(RVA0x2B43904).
Checklist khi tự làm
- [ ] Hai tầng pool, phân loại theo "có state hay không": pool hạt (
Emit) cho đa số VFX, pool GameObject chỉ cho thứ có collider/state. - [ ] Kênh hiệu ứng là enum / hằng số đánh chỉ số, không phải chuỗi.
- [ ] Throttle bắt buộc với ba tiêu chí:
MIN_INTERVAL,MIN_DIST_SQ,MAX_PER_FRAME— tham số cấu hình được. - [ ]
SpawnFX_Oncecho đạn xuyên / laser: một FX cho cả vòng đời đạn. - [ ] Cache mọi lookup theo instance đã pool (
GetComponent, FX theogunNum). - [ ] Đường nhanh "không có gì đổi" khi đổi skin/biến thể FX.
- [ ] Object tự trả về pool bằng hẹn giờ;
OnDisablecũng đưa về trạng thái nhất quán. - [ ]
ResetState()khi lấy từ pool xoá tất cả state của lần dùng trước. - [ ] Xử lý trường hợp pool trả
null. - [ ] Artist: độ hoành tráng =
count(5 thường / 15 boss), không phải prefab riêng; mọi prefab hiệu ứng nằm sẵn trong scene (SOT không dùng Addressables/AssetBundle).
Điều chưa biết (UNKNOWN)
- ~~Thân
FXThrottle.CanSpawn~~: ĐÃ DỰNG (G410) —Record[65](một/khẩu); tối đa 3 FX trúng / frame; theo súng từ chối chỉ khi cả < 0.05 s và < 1.5 u. Stubreturn falsecủa nó từng làm không FX trúng nào hiện — một stub im lặng giết "đã tay". Object_Polling_Smart— ĐÃ dựng lại (G363): con trỏ xoay vòng, hai lượt (inactive → cướp ô chết), null khi bão hoà; không mở rộng, không cấp phát. Quan hệ vớiObject_Polling(RVA0x2AC6B04) vẫn chưa đọc.Polling_manager.Object_struct: kích thước = số instance scene (27 / 10); chiến lược khi hết = tái dùng ô chết, rồi null (G363).- R13: hai method pool này từng là
return null⇒ cả hệ bắn câm lặng không một ngoại lệ — kiểm factory/pool trước khi ship. instance_slime(Particle_index == 2): không quan sát thấy call-site nào trong các class đã dựng lại. Ai dùng thì chưa biết.- 7 991 và 4 957 instance MonoBehaviour thuộc 2 GUID script đông nhất scene (
d3e719b5…,67dfb1fd…): UNKNOWN — không ánh xạ sang class nào củaAssembly-CSharp; là script trong DLL plugin (ứng viên: ParticleImage, spine-unity, AllIn1SpriteShader). Không được đoán. - Số ParticleSystem thuộc gameplay vs UI: UNKNOWN — chỉ biết tổng 7 718.
- ~~
ParticleImageđược dùng ở đâu~~: ĐÃ ĐÓNG (G404) —Dia_Attraction.Dia_SpawngọiParticleImage.EmitAtWorldPosition(vị trí, count): dia bay về ví bằng hạt UI. Bẫy R2: bản package trong project cũ hơn bản game ship (thiếuEmitAt*Position) ⇒ phải tải đúng version đã sở hữu, không dựng lại code package. - Cách khoá: đọc
Polling_manager+FXThrottletừ ARM64 tại RVA đã ghi; tra ngược 2 GUID trong[report: l1_script_identity.json]mở rộng sang assembly plugin.
Đọc thêm
docs/framework/03-particles-vfx.md— nguồn chínhdocs/framework/02-combat-projectiles.md—Bullet.ResetState,SpawnFX,_hitIdsdocs/framework/06-juice-game-feel.md— hợp đồng pooling cho component nảydocs/framework/08-architecture-and-scale.md— vì sao 7 718 ParticleSystem nằm trong một scenegap list (nội bộ)— G124 (Bullet), G135 (Destroy, Bullet_FX đã ship)