Unity 2D 粒子模擬系統——各模組詳細介紹與程式碼

郭俊鑫郭俊鑫
6 min read

以下章節將逐一介紹本專案的模組化 C# 腳本,並附上完整程式碼範例與重點說明,讓你在部落格中能直接貼上閱讀。


1. ParticleManager.cs

功能:負責粒子(GameObject)實例化、質量設定、顏色與標籤管理

// ParticleManager.cs
using UnityEngine;
using TMPro;
using System.Collections.Generic;

public class ParticleManager : MonoBehaviour
{
    public GameObject particlePrefab;       // 粒子 Prefab (含 Rigidbody2D + SpriteRenderer)
    public GameObject labelPrefab;          // 標籤 Prefab (TextMeshPro)
    public Canvas worldCanvas;              // 世界畫布,用來顯示粒子標籤
    public PhysicsMaterial2D particlePhysicsMaterial;
    public float baseParticleScale = 0.2f;
    private int nextParticleID = 0;

    // 儲存所有粒子實例與對應資料
    public List<GameObject> Particles { get; private set; } = new();
    public List<TMP_Text> Labels { get; private set; } = new();
    public List<Color> ParticleColors { get; private set; } = new();
    public List<int> ParticleIDs { get; private set; } = new();

    // 根據位置、速度、質量建立一顆新粒子
    public void CreateParticle(Vector2 position, Vector2 velocity, float mass)
    {
        // 1. 實例化粒子並設定 Rigidbody2D
        GameObject particle = Instantiate(particlePrefab, position, Quaternion.identity);
        var rb = particle.GetComponent<Rigidbody2D>();
        if (rb != null)
        {
            rb.mass = mass;
            rb.velocity = velocity;
            if (particlePhysicsMaterial != null)
                rb.sharedMaterial = particlePhysicsMaterial;
        }

        // 2. 根據質量調整尺寸與顏色
        float scale = baseParticleScale * Mathf.Pow(mass, 1f / 3f);
        particle.transform.localScale = Vector3.one * scale;
        var sr = particle.GetComponent<SpriteRenderer>();
        Color color = GetColorByMass(mass);
        if (sr != null) sr.color = color;

        // 3. 建立標籤
        var label = Instantiate(labelPrefab, worldCanvas.transform).GetComponent<TMP_Text>();
        label.text = ""; // 稍後由外部更新文本

        // 4. 登記管理列表
        Particles.Add(particle);
        Labels.Add(label);
        ParticleColors.Add(color);
        ParticleIDs.Add(nextParticleID++);
    }

    // 質量對應顏色函式
    private Color GetColorByMass(float mass)
    {
        return mass switch
        {
            < 2f => Color.blue,
            < 4f => Color.green,
            < 8f => Color.yellow,
            < 16f => new Color(1f, 0.5f, 0f),
            _ => Color.red
        };
    }
}

重點說明

  • CreateParticle 封裝粒子實例化全流程,方便後續「初始生成」與「分裂時」共用。

  • 使用 Mathf.Pow(mass, 1/3f)尺寸質量對應,物理感更強。

  • GetColorByMassswitch expression支援質量分級著色。


2. GravitationalSystem.cs

功能:處理粒子間的萬有引力、合併 (Merge) 與碎裂 (Fragment) 邏輯

// GravitationalSystem.cs
using UnityEngine;
using System;

public class GravitationalSystem : MonoBehaviour
{
    public float gravitationalConstant = 1f;
    public float mergeDistance = 0.2f;
    public float gravityCutoffDistance = 5f;
    public bool ignoreCutoff = false;
    public int maxParticles = 200;

    [HideInInspector] public ParticleManager particleManager;
    public Action<string> OnCollisionLog;

    // 逐對施加物理互動
    public void ApplyForces()
    {
        var particles = particleManager.Particles;
        for (int i = 0; i < particles.Count; i++)
        {
            var rbA = particles[i].GetComponent<Rigidbody2D>();
            if (rbA == null) continue;
            for (int j = i + 1; j < particles.Count; j++)
            {
                var rbB = particles[j].GetComponent<Rigidbody2D>();
                if (rbB == null) continue;

                Vector2 dir = rbB.position - rbA.position;
                float dist = Mathf.Max(dir.magnitude, 0.1f);
                float relVel = (rbA.velocity - rbB.velocity).magnitude;

                // 合併或分裂
                if (dist < mergeDistance)
                {
                    if (relVel < 0.5f) MergeParticles(i, j, rbA, rbB);
                    else if (relVel > 3f && rbA.mass >= 1f && rbB.mass >= 1f)
                        FragmentParticles(i, j, rbA, rbB);
                    continue;
                }

                // 距離限制
                if (!ignoreCutoff && dist > gravityCutoffDistance) continue;

                // 萬有引力
                Vector2 force = gravitationalConstant * (rbA.mass * rbB.mass / (dist * dist)) * dir.normalized;
                rbA.AddForce(force);
                rbB.AddForce(-force);
            }
        }
    }

    private void MergeParticles(int i, int j, Rigidbody2D rbA, Rigidbody2D rbB)
    {
        // 動量守恆合併
        float totalMass = rbA.mass + rbB.mass;
        rbA.velocity = (rbA.velocity * rbA.mass + rbB.velocity * rbB.mass) / totalMass;
        rbA.mass = totalMass;
        OnCollisionLog?.Invoke($"Merge {i}+{j}{i}, Mass={totalMass:F1}");

        Destroy(particleManager.Particles[j]);
        Destroy(particleManager.Labels[j].gameObject);
        particleManager.Particles.RemoveAt(j);
        particleManager.Labels.RemoveAt(j);
        particleManager.ParticleColors.RemoveAt(j);
        particleManager.ParticleIDs.RemoveAt(j);
    }

    private void FragmentParticles(int i, int j, Rigidbody2D rbA, Rigidbody2D rbB)
    {
        // 分裂邏輯請參考原始程式碼,這裡略
    }
}

重點說明

  • ApplyForces 將合併 / 碎裂 / 引力完整混合運算

  • 合併以「動量守恆」,分裂以「能量+動量守恆」(請參考 HandleFragmentation

  • OnCollisionLog 事件可將文字傳給 LogExporter 處理


3. EnergyTracker.cs

功能:定時統計全局動能、更新數值顯示與繪製能量柱狀圖

// EnergyTracker.cs
using UnityEngine;
using TMPro;
using System.Collections.Generic;

public class EnergyTracker : MonoBehaviour
{
    public ParticleManager particleManager;
    public TMP_Text energyValueText;
    public RectTransform energyGraphContainer;
    public GameObject energyBarPrefab;
    public int maxEnergyBars = 100;

    private List<float> energyHistory = new();
    private float logTimer = 0f;
    public float energyLogInterval = 0.5f;

    public void TrackEnergy(float deltaTime)
    {
        logTimer += deltaTime;
        if (logTimer < energyLogInterval) return;
        logTimer = 0f;
        LogTotalKineticEnergy();
    }

    private void LogTotalKineticEnergy()
    {
        float total = 0f;
        foreach (var p in particleManager.Particles)
        {
            var rb = p.GetComponent<Rigidbody2D>();
            if (rb != null)
                total += 0.5f * rb.mass * rb.velocity.sqrMagnitude;
        }
        energyValueText.text = $"目前總動能:{total:F2}";

        energyHistory.Add(total);
        if (energyHistory.Count > maxEnergyBars)
            energyHistory.RemoveAt(0);

        // 繪製新柱狀圖
        foreach (Transform c in energyGraphContainer) Destroy(c.gameObject);
        float height = energyGraphContainer.rect.height;
        float width = energyGraphContainer.rect.width / energyHistory.Count;
        float maxVal = Mathf.Max(1f, Mathf.Max(energyHistory.ToArray()));
        for (int i = 0; i < energyHistory.Count; i++)
        {
            float norm = energyHistory[i] / maxVal;
            var bar = Instantiate(energyBarPrefab, energyGraphContainer);
            var rt = bar.GetComponent<RectTransform>();
            rt.anchorMin = rt.anchorMax = rt.pivot = new Vector2(0, 0);
            rt.anchoredPosition = new Vector2(i * width, 0);
            rt.sizeDelta = new Vector2(width - 1f, norm * height);
        }
    }
}

重點說明

  • TrackEnergyUpdate() 內呼叫,採用 deltaTime 計時

  • energyHistory 保留最近 N 筆資料,動態生成 UI Image 形成折線圖

  • 效率考量:若資料量大,可改為預先 pool bar 物件


4. SimulationUIManager.cs & SimulationUIBindings.cs

功能:同步UI參數到模組、連接按鈕/滑桿事件

// SimulationUIManager.cs
public class SimulationUIManager : MonoBehaviour
{
    public Toggle ignoreCutoffToggle;
    public TMP_Text ignoreCutoffLabel;
    public Slider gravityCutoffSlider;
    public TMP_Text gravityCutoffValueText;
    public TMP_Text particleCountText;

    public bool IgnoreCutoff { get; private set; }
    public float GravityCutoffDistance { get; private set; }

    public void SyncUIState()
    {
        IgnoreCutoff = ignoreCutoffToggle.isOn;
        ignoreCutoffLabel.text = IgnoreCutoff ? "遠距離引力:ON" : "遠距離引力:OFF";

        GravityCutoffDistance = gravityCutoffSlider.value;
        gravityCutoffValueText.text = $"引力範圍:{GravityCutoffDistance:F1}";
    }
}

// SimulationUIBindings.cs
public class SimulationUIBindings : MonoBehaviour
{
    public SimulationController controller;
    public Button pauseButton;
    public Button resetButton;
    public Slider timeScaleSlider;
    public TMP_Text speedLabel;

    private bool isPaused = false;

    private void Start()
    {
        pauseButton.onClick.AddListener(TogglePause);
        resetButton.onClick.AddListener(controller.ResetSimulation);
        timeScaleSlider.onValueChanged.AddListener(OnTimeScaleChanged);
        speedLabel.text = $"速度:{timeScaleSlider.value:F1}x";
    }

    private void TogglePause()
    {
        isPaused = !isPaused;
        controller.PauseSimulation(isPaused);
        pauseButton.GetComponentInChildren<TMP_Text>().text = isPaused ? "繼續" : "暫停";
    }

    private void OnTimeScaleChanged(float val)
    {
        controller.SetTimeScale(val);
        speedLabel.text = $"速度:{val:F1}x";
    }
}

重點說明

  • SyncUIState():將 Toggle/Slider → ignoreCutoffGravityCutoffDistance 參數同步

  • SimulationUIBindings:為按鈕與滑桿填入 Listener,並顯示當前狀態

  • 兩者可分別掛到 Canvas(UI Manager)與 Panel(Bindings)上


5. LogExporter.cs

功能:檔案與畫面雙重輸出粒子狀態與事件紀錄

// LogExporter.cs
using System.IO;
using System.Text;
using UnityEngine;
using TMPro;

public class LogExporter : MonoBehaviour
{
    public ParticleManager particleManager;
    public TMP_Text logOutputText;

    private string logFilePath;
    private List<string> collisionLog = new();

    public void InitializeLogFile()
    {
        string fn = $"particle_log_{System.DateTime.Now:yyyyMMdd_HHmmss}.txt";
        logFilePath = Path.Combine(Application.persistentDataPath, fn);
        File.WriteAllText(logFilePath, "Time,Index,X,Y,Vx,Vy\n");
        logOutputText.text = "";
    }

    public void AppendCollision(string msg) => collisionLog.Add(msg);

    public void LogParticleStates()
    {
        var sbScreen = new StringBuilder();
        var sbFile = new StringBuilder();
        float t = Time.time;
        for (int i = 0; i < particleManager.Particles.Count; i++)
        {
            var p = particleManager.Particles[i];
            var rb = p?.GetComponent<Rigidbody2D>();
            if (rb == null) continue;
            sbScreen.AppendLine($"[{t:F2}] P{i}: ({rb.position.x:F2},{rb.position.y:F2}) v({rb.velocity.x:F2},{rb.velocity.y:F2})");
            sbFile.AppendLine($"{t:F2},{i},{rb.position.x:F2},{rb.position.y:F2},{rb.velocity.x:F2},{rb.velocity.y:F2}");
        }
        logOutputText.text = sbScreen.ToString();
        File.AppendAllText(logFilePath, sbFile.ToString());
        if (collisionLog.Count > 0) File.AppendAllLines(logFilePath, collisionLog);
        collisionLog.Clear();
    }
}

重點說明

  • InitializeLogFile:建立新檔並寫入 CSV 標頭

  • LogParticleStates:畫面與檔案同步輸出

  • 綁定 GravitationalSystem.OnCollisionLog 可自動記錄事件文字


6. SimulationController.cs

功能:統合各模組、控制流程、重置 & 暫停 & 時間倍率

// SimulationController.cs
using UnityEngine;

public class SimulationController : MonoBehaviour
{
    [Header("UI Inputs")]
    public TMPro.TMP_InputField particleCountInput;
    public TMPro.TMP_InputField speedLimitInput;
    public ParticleManager particleManager;
    public GravitationalSystem gravitationalSystem;
    public EnergyTracker energyTracker;
    public SimulationUIManager uiManager;
    public LogExporter logExporter;

    public float logInterval = 1f;
    private float logTimer;
    private bool isPaused;
    public float timeScale = 1f;

    private void Start()
    {
        logExporter.InitializeLogFile();
        gravitationalSystem.particleManager = particleManager;
        energyTracker.particleManager = particleManager;
        logExporter.particleManager = particleManager;
        GenerateParticlesFromInput();
    }

    private void Update()
    {
        if (isPaused) return;
        Time.timeScale = timeScale;
        uiManager.SyncUIState();
        gravitationalSystem.ignoreCutoff = uiManager.IgnoreCutoff;
        gravitationalSystem.gravityCutoffDistance = uiManager.GravityCutoffDistance;
        uiManager.UpdateParticleCountUI(particleManager.Particles.Count, gravitationalSystem.maxParticles);
        gravitationalSystem.ApplyForces();
        energyTracker.TrackEnergy(Time.deltaTime);
        logTimer += Time.deltaTime;
        if (logTimer >= logInterval)
        {
            logTimer = 0f;
            logExporter.LogParticleStates();
        }
    }

    public void ResetSimulation() { /* … */ }
    public void PauseSimulation(bool p) => isPaused = p;
    public void SetTimeScale(float s) => timeScale = Mathf.Clamp(s, 0.1f, 10f);
    public void GenerateParticlesFromInput() { /* … */ }
    public void OnCollisionEvent(string msg) => logExporter.AppendCollision(msg);
}

重點說明

  • Start():初始化、綁定、呼叫 GenerateParticlesFromInput 產生初始粒子

  • Update():循序執行 UI、物理、能量與日誌模組

  • 提供 ResetSimulationPauseSimulationSetTimeScaleGenerateParticlesFromInput 等控制接口


0
Subscribe to my newsletter

Read articles from 郭俊鑫 directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

郭俊鑫
郭俊鑫