CatGunner CookbookBardCat 1.1.59 · L1 ANALYSIS · tài liệu học
Trang chủCookbook › 09 — VFX & pooling
Nguồn: 09-vfx-pooling.md · Cập nhật lần cuối: 2026-09-05 08:45 +07 · sha256 nguồn 5c86f4c2a171

09 — VFX & pooling

Nguồn framework: docs/framework/03-particles-vfx.md toà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)
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)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]
FXThrottlestatic 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 SetActive mỗ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 = 3 là 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 trong static Dictionary<GameObject, Bullet_FX>; vì pool hữu hạn, cache bão hoà và không bao giờ gọi GetComponent nữa.
  • Đổi skin FX bằng SetActive mỗi phát. Người chơi hiếm khi đổi súng; SOT so _lastGunNum và thoát sớm chỉ với Clear() + 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ờ (Destroy coroutine, Invoke("Deactivate")).
  • Giả định pool luôn trả object. Bullet.SpawnFX kiểm fxObj == 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ẹn Deactivate của nó — G363); bên bắn hoàn lại cooldown.
  • Object lấy từ pool mang state cũ. ResetState() của Bullet phải xoá _hitIds, _pierceCount, cờ; Bounce_Custom.OnEnable reset 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 (RVA 0x2B43904).

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_Once cho đạn xuyên / laser: một FX cho cả vòng đời đạn.
  • [ ] Cache mọi lookup theo instance đã pool (GetComponent, FX theo gunNum).
  • [ ] Đường nhanh "không có gì đổi" khi đổi skin/biến thể FX.
  • [ ] Object tự trả về pool bằng hẹn giờ; OnDisable cũ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 < 1.5 u. Stub return false củ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ới Object_Polling (RVA 0x2AC6B04) 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ủa Assembly-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_Spawn gọi ParticleImage.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ếu EmitAt*Position) ⇒ phải tải đúng version đã sở hữu, không dựng lại code package.
  • Cách khoá: đọc Polling_manager + FXThrottle từ 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