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-12 07:24 +07 · sha256 nguồn 6d421f09b06d

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)
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 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).

Ba điều rút từ hồ đối tượng của bản gốc (2026-09-12)

1. Hồ trả về object mà KHÔNG bật — người gọi phải bật. Bản gốc có hai tầng: Object_Polling (vòng tròn trần) chỉ trả ô kế tiếp, còn việc SetActive(true) nằm ở hàm sinh (Object_Spawn_Pos). Tách như vậy cho phép người gọi đặt vị trí/cỡ TRƯỚC khi bật, nên khung hình đầu tiên không kịp vẽ object ở chỗ cũ. Nếu hồ tự bật, bạn sẽ thấy hiệu ứng "nháy" ở toạ độ lần dùng trước.

2. Đặt lại cỡ, đừng nhân vào cỡ đang có. localScale = Vector3.one * Scale, KHÔNG phải localScale *= Scale. Hồ dùng lại object, nên nhân dồn làm hiệu ứng phình to dần qua mỗi lượt tái sử dụng — lỗi này chỉ lộ sau vài phút chơi, rất dễ lọt nghiệm thu.

3. Hai chiến lược lấy ô, dùng cho hai loại nội dung. Vòng tròn TRẦN cho hiệu ứng (đè lên một cái đang chạy là chấp nhận được); bản "thông minh" cho đạn: quét tìm ô đang TẮT trước, không có thì CƯỚP ô của viên đã chết mà chưa kịp thu hồi (huỷ luôn lệnh thu hồi đang chờ), và trả null khi mọi ô đều sống — chính cái null đó làm người gọi HOÀN lại phát bắn thay vì nuốt mất.

Mẹo đo: con trỏ vòng của hồ là một đầu dò quan sát rẻ và chắc. Muốn biết đường sinh hiệu ứng có thật sự chạy không, đừng soi pixel (chớp nòng và vệt đạn lẫn vào hiệu ứng va chạm, và hai lượt quay hiếm khi so khung được) — đọc con trỏ: đứng im là đường đó chết. Đọc kèm một hồ khác mà bạn BIẾT CHẮC phải nhích, để có đối chứng dương ngay trong cùng phép đo.

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 và < 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