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

03 — Particle & VFX

Nguồn: chưng cất từ restore SOT com.Chodun.CatGunner 1.1.59 (BardCat). Mọi C# ở đây là L1 ANALYSIS, không phải source gốc.


1. Câu hỏi tài liệu này trả lời

Game idle sinh 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ỡ. Nếu mỗi hiệu ứng là một Instantiate thì GC sẽ giết frame-rate trên điện thoại. CatGunner ship 7 718 ParticleSystem trong một scene duy nhất và vẫn chạy — tài liệu này mô tả cách nó làm được.


2. Stable truths

Claim Mức bằng chứng Nguồn / provenance
Scene Main.unity chứa 7 718 ParticleSystem và 7 718 ParticleSystemRenderer scene-census [report: asset_lane.md] §4a (UnityPy 150 958 object)
hai hệ pool độc lập: Particle_Polling (pool hạt, emit tại chỗ) và Polling_manager (pool GameObject) 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 disasm-proven Awake RVA 0x2ABB3AC
Particle_Polling.Spawn(Vector3 Pos, int Count) dùng ParticleSystem.EmitParams { position = Pos, applyShapeToPosition = true }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 } metadata-proven dump.cs:589342, :589369
Polling_managerObject_Polling(type)Object_Polling_Smart(type); runtime cả hai đều chạy 207× trong cửa sổ 40 s (đúng bằng số phát bắn) metadata-proven + runtime-observed dump.cs:589369; [runtime trace: runtime_ranking.json]
FXThrottlestatic class với hằng số: MIN_INTERVAL = 0.05, MIN_DIST_SQ = 2.25 (= bán kính 1.5), MAX_PER_FRAME = 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, dùng trong SpawnFX
Bullet_FX.Skin_Reload(int) bỏ qua toàn bộ việc đổi skin nếu _lastGunNum == GunNum, chỉ Clear() + Play() disasm-proven RVA 0x2B096FC; .ctor đặt _lastGunNum = -1
Bullet_Mesh (1 215 instance) bật/tắt mảng ParticleSystem[]: Active() = Play() mọi phần tử; Stop() = Stop() + Clear() disasm-proven + runtime-observed RVA 0x2B07A70 / 0x2B079EC; Stop fired 387×, Active 207×
Bụi khi phá quặng: Particle_Polling.instance_dust.Spawn(DustSpawn_trans.position, 5) disasm-proven Ore.Die_Reload RVA 0x2AF76E4
Hiệu ứng khi cây đổ: Particle_Polling.instance.Spawn(MoneySpawn_trans.position, is_Boss ? 15 : 5) disasm-proven Tree.Die_Reload RVA 0x2B43904
Cây stage speed-run: Particle_Polling.instance.Spawn(pos, 5) disasm-proven Tree_SpeedRun.Die_Reload RVA 0x2B434A0
Destroy component tự SetActive(false) sau Destroy_Time giây bằng coroutine, có huỷ coroutine cũ ở OnEnable/OnDisable disasm-proven + runtime-observed RVA 0x2AC65A0…; OnEnable 208×, Disable 208×, OnDisable 203×
ParticleImage (hạt trên UI) là package thật của user: 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 (bằng chứng phủ định) [report: asset_lane.md] §3

3. Mô hình hành vi

3.1 Hai kiểu pool cho hai bài toán khác nhau

Đây là điểm kiến trúc quan trọng nhất, và rất dễ làm sai nếu chỉ có một loại pool.

(a) Particle_Polling — pool ở mức HẠT, không phải mức object.

// L1 ANALYSIS — RVA 0x2ABB458
public void Spawn(Vector3 Pos, int Count)
{
    ParticleSystem.EmitParams emitParams = default(ParticleSystem.EmitParams);
    emitParams.position = Pos;
    emitParams.applyShapeToPosition = true;
    for (int i = 0; i < _particleSystem.Length; i++)
        _particleSystem[i].Emit(emitParams, Count);
}

Không có GameObject nào được tạo, bật, hay tắt. Có một ParticleSystem (hoặc một nhóm nhỏ) tồn tại vĩnh viễn, và mỗi lần cần hiệu ứng ta chỉ bơm thêm hạt vào nó tại một toạ độ. Unity tự quản lý buffer hạt; chi phí mỗi lần "spawn" gần bằng 0.

applyShapeToPosition = true nghĩa là shape module của system (hình cầu, hình nón…) vẫn được áp tương đối so với Pos, nên vẫn có tán xạ ngẫu nhiên như một burst bình thường.

Ba slot static (instance, instance_dust, instance_slime) là ba "kênh" hiệu ứng khác nhau (vàng rơi / bụi đá / nhớt slime), mỗi kênh một pool riêng, đăng ký bằng Particle_index đặt trong inspector. Không có lookup theo tên, không có dictionary — chỉ là ba biến static.

(b) Polling_manager — pool ở mức GAMEOBJECT, cho thứ cần state riêng.

Chỉ hai loại: BulletBullet_FX. Đây là những thứ không thể là hạt vì chúng cần collider, Rigidbody, và trạng thái riêng (_hitIds, _pierceCount). Runtime cho thấy cả Object_Polling lẫn Object_Polling_Smart chạy 207 lần — đúng bằng số lần bắn — nên mỗi phát bắn lấy một Bullet và (qua Bullet.SpawnFX) một Bullet_FX từ pool.

Quy tắc rút ra: nếu hiệu ứng không cần state hay va chạm, đừng pool GameObject — hãy pool hạt. 7 718 ParticleSystem trong scene chỉ khả thi vì phần lớn chúng là những system tồn tại sẵn được Emit() vào, chứ không phải object được sinh/huỷ.

3.2 Throttle FX — ba tầng chặn

// FXThrottle (static class) — dump.cs:596485
private static readonly Record[] _last;      // ghi nhớ lần spawn cuối theo gunNum
private const float MIN_INTERVAL = 0.05f;    // tối đa 20 FX/giây cho cùng một nguồn
private const float MIN_DIST_SQ  = 2.25f;    // = 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;      // trần cứng mỗi frame

public static bool CanSpawn(int gunNum, Vector3 pos) { … }

Ba tiêu chí độc lập: 1. Theo thời gian — cùng một khẩu súng không sinh FX nhanh hơn 20 Hz. 2. Theo không gian — FX cách lần trước dưới 1.5 đơn vị bị bỏ (mắt người không phân biệt được, nhưng GPU vẫn phải vẽ). 3. Theo frame — dù có gì xảy ra, tối đa 3 FX mới mỗi frame.

Tiêu chí (3) là thứ cứu frame-rate ở những khoảnh khắc tệ nhất (nổ AoE trúng 20 quái cùng lúc). Hai tiêu chí đầu giảm rác trong trạng thái bình thường.

Và ở tầng gọi, Bullet còn có SpawnFX_Once — chỉ cho phép một FX cho toàn bộ vòng đời một viên đạn xuyên/laser, bất kể nó trúng bao nhiêu mục tiêu.

3.3 Tránh GetComponent trên hot path

// 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;               // static Dictionary<GameObject, Bullet_FX>
}
if (fx != null) fx.Spawn(gunNum, pos);

Vì pool có số phần tử hữu hạn và cố định, dictionary này bão hoà sau vài frame đầu và sau đó không bao giờ gọi GetComponent nữa. Đây là mẫu chuẩn cho mọi pool: cache lookup theo instance đã pool, không theo prefab.

Bullet_FX.Skin_Reload áp dụng cùng ý tưởng ở tầng thấp hơn:

// RVA 0x2B096FC — .ctor đặt _lastGunNum = -1
if (_lastGunNum == GunNum) { particle?.Clear(); particle?.Play(); return; }  // đường nhanh
particle?.Stop(); particle.gameObject.SetActive(false);
particle = Skin_particles[GunNum];
particle.gameObject.SetActive(true);
particle.Clear(); particle.Play();
_lastGunNum = GunNum;

Vì người chơi hiếm khi đổi súng, nhánh nhanh chạy gần như 100 % thời gian: không SetActive, không đổi tham chiếu, chỉ Clear() + Play() — cách rẻ nhất để "phát lại" một burst.

3.4 Trả object về pool bằng thời gian, không bằng callback

// 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); }

Bullet dùng Invoke("Deactivate", _deactivateDelay) (mặc định 1f) thay vì coroutine.

Cả hai đều là hẹn giờ tự trả về, không phải "hiệu ứng báo cho pool khi xong". Đơn giản hơn, và quan trọng hơn: không thể rò rỉ — kể cả khi logic gọi bị lỗi, object vẫn tự tắt.

Chi tiết đáng chú ý: Destroy.OnDisable cũng gọi SetActive(false) khi coroutine còn sống. Điều đó khiến một object bị tắt sớm vẫn ở trạng thái nhất quán khi được lấy lại từ pool.

3.5 Số lượng hiệu ứng theo ngữ cảnh

Hằng số Count truyền vào Spawn là cần gạt "độ hoành tráng":

Sự kiện Kênh Count
Quặng vỡ instance_dust 5
Cây thường đổ instance 5
Cây boss đổ instance 15
Cây speed-run đổ instance 5

Một dòng is_Boss ? 15 : 5 là toàn bộ sự khác biệt giữa "chết thường" và "chết boss" ở tầng hạt. Rẻ, dễ chỉnh, và không cần prefab riêng.


4. Framework takeaway

  1. Hai tầng pool, phân loại theo "có state hay không". IParticleBurstPool : Emit(channel, position, count) // không GameObject, chi phí ~0 IObjectPool<T> : Rent(type) / tự trả về theo hẹn giờ // cho thứ có collider/state Đa số VFX thuộc loại thứ nhất. Nếu framework chỉ cung cấp loại thứ hai, người dùng sẽ pool GameObject cho mọi thứ và trả giá bằng hàng nghìn SetActive mỗi giây.

  2. Kênh hiệu ứng là hằng số đánh chỉ số, không phải chuỗi. Particle_index 0/1/2 → instance/instance_dust/instance_slime. Không lookup theo tên, không dictionary trên hot path. Framework nên phơi ra một enum kênh.

  3. Throttle là hạ tầng bắt buộc, không phải tuỳ chọn. Ba tiêu chí — thời gian, khoảng cách, trần mỗi frame — nên nằm sẵn trong IParticleBurstPool với tham số cấu hình được. Đặc biệt trần mỗi frame là thứ duy nhất cứu được kịch bản xấu nhất.

  4. Cache mọi lookup theo instance đã pool. Pool có kích thước hữu hạn ⇒ cache bão hoà ⇒ chi phí lookup về 0. Áp dụng cho GetComponent, cho Bullet_FX theo gunNum, cho mọi thứ.

  5. Đường nhanh "không có gì đổi" cho việc đổi skin/biến thể FX. So sánh với giá trị lần trước và thoát sớm. Người chơi đổi trang bị hiếm; đừng trả giá cho nó mỗi lần bắn.

  6. Object tự trả về pool bằng hẹn giờ. Không phụ thuộc callback từ logic gameplay — nó sẽ có ngày không được gọi.

  7. Độ hoành tráng là một số nguyên count, không phải một prefab khác.


5. Negative evidence & UNKNOWN

Mục Trạng thái
Thân FXThrottle.CanSpawn (RVA 0x2B09320) ĐÃ DỰNG (G410): .cctor Record[65] (một bản ghi mỗi khẩu súng); tối đa 3 FX trúng / frame; theo từng súng chỉ từ chối khi cả < 0.05 s < 1.5 u so với lần trước. Stub return false của nó = không FX trúng nào từng hiện.
Thân Polling_manager.Object_Polling_Smart (0x2AC6900) + Object_struct.Object_Polling_Smart (0x2AC6934) ĐÃ dựng lại (G363): con trỏ xoay vòng, hai lượt — lượt 1 lấy object inactive đầu tiên; không có ⇒ lượt 2 cướp ô Bullet đã chết đầu tiên và huỷ Invoke("Deactivate") của nó; null chỉ khi mọi ô còn sốngCat.Update hoàn lại cooldown. Quan hệ với Object_Polling (0x2AC6B04) vẫn chưa đọc.
Polling_manager.Object_struct (cấu trúc pool) ĐÓNG MỘT PHẦN: không mở rộng, không cấp phát — tái dùng ô chết, trả null khi bão hoà (G363). Kích thước = số instance scene (27 Bullet, 10 Bullet_FX). Bullet.SpawnFX kiểm fxObj == null là đúng đắn. R13: hai method này từng là return null ⇒ cả hệ bắn câm lặng không một ngoại lệ.
instance_slime (Particle_index == 2) Không quan sát thấy call-site nào trong các class đã dựng lại. Nó tồn tại và được đăng ký, nhưng ai dùng thì chưa biết.
7 991 và 4 957 instance MonoBehaviour trong scene thuộc 2 GUID script UNKNOWN — hai GUID đông nhất trong scene (d3e719b5…, 67dfb1fd…) không ánh xạ được sang class nào của Assembly-CSharp; chúng là script nằm trong DLL plugin (ứng viên: ParticleImage, spine-unity, AllIn1SpriteShader). Không được đoán.
Số lượng ParticleSystem thuộc về gameplay vs UI UNKNOWN — chỉ biết tổng 7 718. Chưa phân tách.
ParticleImage được dùng ở đâu ĐÃ ĐÓNG (G404): Dia_Attraction.Dia_Spawn (0x2AD456C) tail-call AssetKits.ParticleImage.ParticleImage.EmitAtWorldPosition(Vector3, int count = 1) (0x484C460) — dia bay về ví bằng hạt UI. R2 bẫy version: project đang mang bản cũ hơn của "UI Particle Image" (chỉ Particle.cs, ParticleImage.cs, ParticleTrailRenderer.cs; thiếu EmitAt*Position) ⇒ compile đỏ toàn lane; phải tải đúng version đã ship (owned), không dựng lại code package.

Cách khoá các UNKNOWN trên: đọc Polling_manager + FXThrottle từ ARM64 tại RVA đã ghi; và tra ngược 2 GUID kia trong [report: l1_script_identity.json] mở rộng sang các assembly plugin.


6. Raw evidence

  • [reconstructed: Particle_Polling.cs] — pool hạt (disasm-proven)
  • [reconstructed: Bullet_FX.cs] — FX trúng đích có pool
  • [reconstructed: Bullet_Mesh.cs] — mảng ParticleSystem bật/tắt
  • [reconstructed: Destroy.cs] — tự trả về pool theo hẹn giờ
  • [reconstructed: Ore.cs] — call-site instance_dust
  • [evidence codegen: Tree.cs] — call-site instance, count 15 vs 5
  • [evidence codegen: Bullet.cs]SpawnFX / SpawnFX_Once / _fxCache
  • dump.cs:596485 (FXThrottle), :589342 + :589369 (Objects_Type, Polling_manager)
  • [runtime trace: runtime_ranking.json] — tần suất thật
  • [report: asset_lane.md] §3, §4a — thống kê scene

7. Đã đóng bởi FEEL lane (2026-09-05) — cập nhật cho §5

UNKNOWN cũ Kết quả
Thân FXThrottle.CanSpawn (0x2B09320) if (frame != _frameCount) { _frameCount = frame; _spawnThisFrame = 0; } if (_spawnThisFrame > 2) return false; ref var r = ref _last[gunNum]; if (t - r.time < 0.05f && (pos - r.pos).sqrMagnitude < 2.25f) return false; r = (t, pos); _spawnThisFrame++; return true;.cctor _last = new Record[65]. Stub cũ trả false vô điều kiện → không FX trúng đích nào từng spawn (R13).
ParticleImage dùng ở đâu Dia_Attraction._diaFx (scene fileID 119325, _duration 0.3, _loop 0, _onParticleFinish → Dia_FX_End) — hiệu ứng dia bay về HUD, attractor trỏ vào Dia_Attraction_Target mới bật nhất.
API ParticleImage bản ship vs project Bản ship có EmitAtLocalPosition / EmitAtWorldPosition / EmitAtScreenPosition (RVA 0x484C1D0 / 0x484C460 / 0x484C4C4); project (1.2.0) và bản sở hữu (1.2.2) không cóDia_Spawn để trống (GAPS G1503).
Particle_Polling Đối chiếu lại toàn bộ 3 RVA — bản đã ship khớp. Caller: Ore.Die_Reload, Monster.Die_Reload, Tree_SpeedRun.Die_Reload, Tree.Die_Reload.