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

以下章節將逐一介紹本專案的模組化 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)
讓尺寸與質量對應,物理感更強。GetColorByMass
用switch 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);
}
}
}
重點說明:
TrackEnergy
在Update()
內呼叫,採用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 →ignoreCutoff
、GravityCutoffDistance
參數同步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、物理、能量與日誌模組提供
ResetSimulation
、PauseSimulation
、SetTimeScale
、GenerateParticlesFromInput
等控制接口
Subscribe to my newsletter
Read articles from 郭俊鑫 directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
