第一篇 Unity 開發日誌:建立粒子模擬系統

10 min read
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using System.Collections.Generic;
using System.IO;
using System.Diagnostics;
public class ParticleSimulator2D : MonoBehaviour
{
// 所有需要的欄位變數
public GameObject particlePrefab;
public GameObject labelPrefab;
public GameObject wallPrefab;
public TMP_InputField particleCountInput;
public TMP_InputField speedLimitInput;
public TMP_Text logOutputText;
public Canvas worldCanvas;
public Vector2 spawnAreaSize = new Vector2(10f, 10f);
public float logInterval = 1.0f;
public PhysicsMaterial2D particlePhysicsMaterial;
public float gravitationalConstant = 1f;
public float gravityCutoffDistance = 5f;
public float mergeDistance = 0.2f;
public bool ignoreCutoff = false;
public float baseParticleScale = 0.2f;
public int maxParticles = 200;
public Toggle ignoreCutoffToggle;
public TMP_Text ignoreCutoffLabel;
public Slider gravityCutoffSlider;
public TMP_Text gravityCutoffValueText;
public TMP_Text particleCountText;
private List<GameObject> particles = new List<GameObject>();
private List<TMP_Text> labels = new List<TMP_Text>();
private List<Color> particleColors = new List<Color>();
private List<int> particleIDs = new List<int>();
private int nextParticleID = 0;
private float logTimer = 0f;
private string logFilePath;
private List<string> collisionLog = new List<string>();
// 能量紀錄用
private List<float> energyHistory = new List<float>();
public RectTransform energyGraphContainer;
public GameObject energyBarPrefab;
private float energyLogInterval = 0.5f;
public TMP_Text energyValueText;
private float energyLogTimer = 0f;
private int maxEnergyBars = 100;
private Vector2 minBounds;
private Vector2 maxBounds;
private Color GetColorByMass(float mass)
{
return mass switch
{
< 2f => Color.blue,
< 4f => Color.green,
< 8f => Color.yellow,
< 16f => new Color(1f, 0.5f, 0f), // orange
_ => Color.red
};
}
private void Start()
{
CreateBoundaryWalls();
}
private void CreateBoundaryWalls()
{
Vector2 center = transform.position;
Vector2 halfSize = spawnAreaSize / 2f;
Vector2[] positions = new Vector2[] {
center + new Vector2(0, halfSize.y + 0.5f), // Top
center + new Vector2(0, -halfSize.y - 0.5f), // Bottom
center + new Vector2(-halfSize.x - 0.5f, 0), // Left
center + new Vector2(halfSize.x + 0.5f, 0) // Right
};
Vector2[] sizes = new Vector2[] {
new Vector2(spawnAreaSize.x + 2f, 1f),
new Vector2(spawnAreaSize.x + 2f, 1f),
new Vector2(1f, spawnAreaSize.y + 2f),
new Vector2(1f, spawnAreaSize.y + 2f)
};
for (int i = 0; i < 4; i++)
{
GameObject wall = Instantiate(wallPrefab, positions[i], Quaternion.identity);
wall.transform.localScale = sizes[i];
wall.name = "Wall_" + i;
}
}
private void Update()
{
energyLogTimer += Time.deltaTime;
if (energyLogTimer >= energyLogInterval)
{
energyLogTimer = 0f;
LogTotalKineticEnergy();
}
if (ignoreCutoffToggle != null)
{
ignoreCutoff = ignoreCutoffToggle.isOn;
if (ignoreCutoffLabel != null)
ignoreCutoffLabel.text = ignoreCutoff ? "遠距離仍作用引力 (ON)" : "遠距離仍作用引力 (OFF)";
}
if (gravityCutoffSlider != null)
{
gravityCutoffDistance = gravityCutoffSlider.value;
if (gravityCutoffValueText != null)
gravityCutoffValueText.text = $"作用範圍距離:{gravityCutoffDistance:F1}";
}
if (string.IsNullOrEmpty(logFilePath)) return;
logTimer += Time.deltaTime;
if (logTimer >= logInterval)
{
logTimer = 0f;
LogParticleStates();
}
ApplyGravitationalForces();
ClampParticlesToBounds();
if (particleCountText) particleCountText.text = $"粒子數:{particles.Count} / {maxParticles}";
for (int i = 0; i < particles.Count; i++)
{
if (!particles[i] || !labels[i]) continue;
int id = i < particleIDs.Count ? particleIDs[i] : -1;
var rb = particles[i].GetComponent<Rigidbody2D>();
if (!rb) continue;
Vector2 pos = rb.position;
Vector2 vel = rb.velocity;
labels[i].text = $"ID:{id}\nPos({pos.x:F1},{pos.y:F1})\nVel({vel.x:F1},{vel.y:F1})";
Vector3 screenPos = Camera.main.WorldToScreenPoint(pos);
labels[i].transform.position = screenPos + new Vector3(0, 30f, 0);
float margin = 20f;
bool offscreen = screenPos.x < margin || screenPos.x > Screen.width - margin ||
screenPos.y < margin || screenPos.y > Screen.height - margin;
labels[i].enabled = !offscreen;
}
}
private void ApplyGravitationalForces()
{
for (int i = 0; i < particles.Count; i++)
{
Rigidbody2D rbA = particles[i].GetComponent<Rigidbody2D>();
if (rbA == null) continue;
for (int j = i + 1; j < particles.Count; j++)
{
HandlePairwiseInteraction(i, j, rbA);
}
}
}
private void HandlePairwiseInteraction(int i, int j, Rigidbody2D rbA)
{
Rigidbody2D rbB = particles[j].GetComponent<Rigidbody2D>();
if (rbB == null) return;
Vector2 direction = rbB.position - rbA.position;
float distance = direction.magnitude;
if (distance < 0.1f) distance = 0.1f;
float relativeVelocity = (rbA.velocity - rbB.velocity).magnitude;
if (distance < mergeDistance)
{
if (relativeVelocity < 0.5f)
{
HandleMerge(i, j, rbA, rbB);
return;
}
else if (relativeVelocity > 3f && rbA.mass >= 1f && rbB.mass >= 1f)
{
HandleFragmentation(i, j, rbA, rbB);
return;
}
}
if (!ignoreCutoff && distance > gravityCutoffDistance) return;
Vector2 force = gravitationalConstant * (rbA.mass * rbB.mass / (distance * distance)) * direction.normalized;
rbA.AddForce(force);
rbB.AddForce(-force);
}
private void HandleMerge(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;
int idA = i < particleIDs.Count ? particleIDs[i] : -1;
int idB = j < particleIDs.Count ? particleIDs[j] : -1;
collisionLog.Add($"{Time.time:F2}s: Merge {idA} + {idB} → {idA}, NewMass={totalMass:F1}");
Destroy(particles[j]);
Destroy(labels[j].gameObject);
particles.RemoveAt(j);
labels.RemoveAt(j);
particleColors.RemoveAt(j);
j--;
}
private void HandleFragmentation(int i, int j, Rigidbody2D rbA, Rigidbody2D rbB)
{
float totalMass = rbA.mass + rbB.mass;
Vector2 explosionCenter = (rbA.position + rbB.position) / 2f;
Vector2 explosionVelocity = (rbA.velocity + rbB.velocity) / 2f;
int fragmentCount = Random.Range(3, 6);
int maxFragments = Mathf.FloorToInt(totalMass);
fragmentCount = Mathf.Min(fragmentCount, maxFragments);
float remainingMass = totalMass;
Vector2[] directions = new Vector2[fragmentCount];
float[] masses = new float[fragmentCount];
Vector2[] velocities = new Vector2[fragmentCount];
Vector2 totalOffsetMomentum = Vector2.zero;
for (int fi = 0; fi < fragmentCount; fi++) {
directions[fi] = Random.insideUnitCircle.normalized;
masses[fi] = (fi < fragmentCount - 1) ? Mathf.Max(1f, Random.Range(1f, remainingMass - 1f * (fragmentCount - fi - 1))) : remainingMass;
remainingMass -= masses[fi];
}
for (int fi = 0; fi < fragmentCount; fi++) {
velocities[fi] = directions[fi] * (1f / Mathf.Sqrt(masses[fi]));
totalOffsetMomentum += velocities[fi] * masses[fi];
}
Vector2 correction = totalOffsetMomentum / totalMass;
float originalKineticEnergy = 0.5f * rbA.mass * rbA.velocity.sqrMagnitude + 0.5f * rbB.mass * rbB.velocity.sqrMagnitude;
float fragmentKineticEnergy = 0f;
for (int fi = 0; fi < fragmentCount; fi++) {
Vector2 rawV = velocities[fi] - correction;
fragmentKineticEnergy += 0.5f * masses[fi] * rawV.sqrMagnitude;
}
float energyScale = Mathf.Sqrt(originalKineticEnergy / Mathf.Max(0.0001f, fragmentKineticEnergy));
for (int fi = 0; fi < fragmentCount && particles.Count < maxParticles; fi++) {
Vector2 adjustedVelocity = explosionVelocity + (velocities[fi] - correction) * energyScale;
GameObject frag = Instantiate(particlePrefab, explosionCenter + directions[fi] * 0.2f, Quaternion.identity);
var fragRb = frag.GetComponent<Rigidbody2D>();
fragRb.mass = masses[fi];
fragRb.velocity = adjustedVelocity;
frag.transform.localScale = Vector3.one * baseParticleScale * Mathf.Pow(masses[fi], 1f / 3f);
var fragSr = frag.GetComponent<SpriteRenderer>();
var fragColor = GetColorByMass(masses[fi]);
if (fragSr != null) fragSr.color = fragColor;
var fragLabel = Instantiate(labelPrefab, worldCanvas.transform).GetComponent<TMP_Text>();
fragLabel.text = "";
particles.Add(frag);
labels.Add(fragLabel);
particleColors.Add(fragColor);
particleIDs.Add(nextParticleID++);
}
GameObject particleA = particles[i];
GameObject labelA = labels[i].gameObject;
int idA = i < particleIDs.Count ? particleIDs[i] : -1;
int jIndex = j > i ? j - 1 : j;
GameObject particleB = jIndex < particles.Count ? particles[jIndex] : null;
GameObject labelB = jIndex < labels.Count ? labels[jIndex].gameObject : null;
int idB = jIndex < particleIDs.Count ? particleIDs[jIndex] : -1;
particles.RemoveAt(i);
labels.RemoveAt(i);
particleColors.RemoveAt(i);
particleIDs.RemoveAt(i);
Destroy(particleA);
Destroy(labelA);
if (jIndex < particles.Count)
{
particles.RemoveAt(jIndex);
labels.RemoveAt(jIndex);
particleColors.RemoveAt(jIndex);
particleIDs.RemoveAt(jIndex);
if (particleB != null) Destroy(particleB);
if (labelB != null) Destroy(labelB);
}
collisionLog.Add($"{Time.time:F2}s: Fragment {idA} + {idB} → {fragmentCount} particles");
}
Rigidbody2D rbB = particles[j].GetComponent<Rigidbody2D>();
if (rbB == null) return;
Vector2 direction = rbB.position - rbA.position;
float distance = direction.magnitude;
if (distance < 0.1f) distance = 0.1f;
float relativeVelocity = (rbA.velocity - rbB.velocity).magnitude;
if (distance < mergeDistance)
{
if (relativeVelocity < 0.5f)
{
HandleMerge(i, j, rbA, rbB);
return;
}
else if (relativeVelocity > 3f && rbA.mass >= 1f && rbB.mass >= 1f)
{
HandleFragmentation(i, j, rbA, rbB);
return;
}
}
if (!ignoreCutoff && distance > gravityCutoffDistance) return;
Vector2 force = gravitationalConstant * (rbA.mass * rbB.mass / (distance * distance)) * direction.normalized;
rbA.AddForce(force);
rbB.AddForce(-force);
}
for (int i = 0; i < particles.Count; i++)
{
Rigidbody2D rbA = particles[i].GetComponent<Rigidbody2D>();
if (rbA == null) continue;
for (int j = i + 1; j < particles.Count; j++)
{
Rigidbody2D rbB = particles[j].GetComponent<Rigidbody2D>();
if (rbB == null) continue;
Vector2 direction = rbB.position - rbA.position;
float distance = direction.magnitude;
if (distance < 0.1f) distance = 0.1f;
float relativeVelocity = (rbA.velocity - rbB.velocity).magnitude;
if (distance < mergeDistance)
{
if (relativeVelocity < 0.5f)
{
float totalMass = rbA.mass + rbB.mass;
rbA.velocity = (rbA.velocity * rbA.mass + rbB.velocity * rbB.mass) / totalMass;
rbA.mass = totalMass;
int idA = i < particleIDs.Count ? particleIDs[i] : -1;
int idB = j < particleIDs.Count ? particleIDs[j] : -1;
collisionLog.Add($"{Time.time:F2}s: Merge {idA} + {idB} → {idA}, NewMass={totalMass:F1}");
Destroy(particles[j]);
Destroy(labels[j].gameObject);
particles.RemoveAt(j);
labels.RemoveAt(j);
particleColors.RemoveAt(j);
j--;
continue;
}
else if (relativeVelocity > 3f && rbA.mass >= 1f && rbB.mass >= 1f)
{
float totalMass = rbA.mass + rbB.mass;
Vector2 explosionCenter = (rbA.position + rbB.position) / 2f;
Vector2 explosionVelocity = (rbA.velocity + rbB.velocity) / 2f;
int fragmentCount = Random.Range(3, 6);
int maxFragments = Mathf.FloorToInt(totalMass);
fragmentCount = Mathf.Min(fragmentCount, maxFragments);
float remainingMass = totalMass;
// 分裂後速度分配:完全動量守恆分裂模型
Vector2[] directions = new Vector2[fragmentCount];
float[] masses = new float[fragmentCount];
Vector2[] velocities = new Vector2[fragmentCount];
Vector2 totalOffsetMomentum = Vector2.zero;
for (int iFrag = 0; iFrag < fragmentCount; iFrag++) {
directions[iFrag] = Random.insideUnitCircle.normalized;
masses[iFrag] = (iFrag < fragmentCount - 1) ? Mathf.Max(1f, Random.Range(1f, remainingMass - 1f * (fragmentCount - iFrag - 1))) : remainingMass;
remainingMass -= masses[iFrag];
}
for (int iFrag = 0; iFrag < fragmentCount; iFrag++) {
velocities[iFrag] = directions[iFrag] * (1f / Mathf.Sqrt(masses[iFrag]));
totalOffsetMomentum += velocities[iFrag] * masses[iFrag];
}
// 平衡總動量偏移:全部減掉平均動量
Vector2 correction = totalOffsetMomentum / totalMass;
// 加入能量守恆:修正動能總和
float originalKineticEnergy = 0.5f * rbA.mass * rbA.velocity.sqrMagnitude + 0.5f * rbB.mass * rbB.velocity.sqrMagnitude;
float fragmentKineticEnergy = 0f;
for (int fi = 0; fi < fragmentCount; fi++) {
Vector2 rawV = velocities[fi] - correction;
fragmentKineticEnergy += 0.5f * masses[fi] * rawV.sqrMagnitude;
}
float energyScale = Mathf.Sqrt(originalKineticEnergy / Mathf.Max(0.0001f, fragmentKineticEnergy));
for (int fi = 0; fi < fragmentCount && particles.Count < maxParticles; fi++) {
Vector2 adjustedVelocity = explosionVelocity + (velocities[fi] - correction) * energyScale;
GameObject frag = Instantiate(particlePrefab, explosionCenter + directions[fi] * 0.2f, Quaternion.identity);
var fragRb = frag.GetComponent<Rigidbody2D>();
fragRb.mass = masses[fi];
fragRb.velocity = adjustedVelocity;
frag.transform.localScale = Vector3.one * baseParticleScale * Mathf.Pow(masses[fi], 1f / 3f);
var fragSr = frag.GetComponent<SpriteRenderer>();
var fragColor = GetColorByMass(masses[fi]);
if (fragSr != null) fragSr.color = fragColor;
var fragLabel = Instantiate(labelPrefab, worldCanvas.transform).GetComponent<TMP_Text>();
fragLabel.text = "";
particles.Add(frag);
labels.Add(fragLabel);
particleColors.Add(fragColor);
particleIDs.Add(nextParticleID++);
}
}
// 儲存刪除前的對象參考
GameObject particleA = particles[i];
GameObject labelA = labels[i].gameObject;
int idA = i < particleIDs.Count ? particleIDs[i] : -1;
int jIndex = j > i ? j - 1 : j;
GameObject particleB = jIndex < particles.Count ? particles[jIndex] : null;
GameObject labelB = jIndex < labels.Count ? labels[jIndex].gameObject : null;
int idB = jIndex < particleIDs.Count ? particleIDs[jIndex] : -1;
// 刪除原始粒子
particles.RemoveAt(i);
labels.RemoveAt(i);
particleColors.RemoveAt(i);
particleIDs.RemoveAt(i);
Destroy(particleA);
Destroy(labelA);
// 刪除另一個粒子(保護性檢查)
if (jIndex < particles.Count)
{
particles.RemoveAt(jIndex);
labels.RemoveAt(jIndex);
particleColors.RemoveAt(jIndex);
particleIDs.RemoveAt(jIndex);
if (particleB != null) Destroy(particleB);
if (labelB != null) Destroy(labelB);
}
collisionLog.Add($"{Time.time:F2}s: Fragment {idA} + {idB} → {fragmentCount} particles");
i--;
break;
}
else
{
// 中等速度會正常碰撞(什麼都不做)
}
}
if (!ignoreCutoff && distance > gravityCutoffDistance) continue;
Vector2 force = gravitationalConstant * (rbA.mass * rbB.mass / (distance * distance)) * direction.normalized;
rbA.AddForce(force);
rbB.AddForce(-force);
}
}
public void GenerateParticles()
{
Vector2 center = transform.position;
minBounds = center - spawnAreaSize / 2f;
maxBounds = center + spawnAreaSize / 2f;
foreach (var p in particles) Destroy(p);
foreach (var l in labels) Destroy(l.gameObject);
particles.Clear();
labels.Clear();
particleIDs.Clear();
particleColors.Clear();
int count = int.TryParse(particleCountInput.text, out int val) ? val : 10;
float maxSpeed = float.TryParse(speedLimitInput.text, out float fval) ? fval : 5f;
for (int i = 0; i < count && particles.Count < maxParticles; i++)
{
Vector2 randomPos = new Vector2(
Random.Range(minBounds.x, maxBounds.x),
Random.Range(minBounds.y, maxBounds.y));
Vector2 velocity = Random.insideUnitCircle.normalized * Random.Range(0, maxSpeed);
CreateParticleInstance(randomPos, velocity, 1f);
}
logTimer = 0f;
string timestamp = System.DateTime.Now.ToString("yyyyMMdd_HHmmss");
logFilePath = Path.Combine(Application.persistentDataPath, $"particle_log_{timestamp}.txt");
File.WriteAllText(logFilePath, "Time,Index,PositionX,PositionY,VelocityX,VelocityY");
if (logOutputText != null) logOutputText.text = "";
}
private void CreateParticleInstance(Vector2 position, Vector2 velocity, float mass)
{
GameObject particle = Instantiate(particlePrefab, position, Quaternion.identity);
Rigidbody2D rb = particle.GetComponent<Rigidbody2D>();
if (rb != null)
{
rb.velocity = velocity;
rb.mass = mass;
particle.transform.localScale = Vector3.one * baseParticleScale * Mathf.Pow(mass, 1f / 3f);
if (particlePhysicsMaterial != null) rb.sharedMaterial = particlePhysicsMaterial;
}
SpriteRenderer sr = particle.GetComponent<SpriteRenderer>();
Color massColor = GetColorByMass(mass);
if (sr != null)
sr.color = massColor;
particleColors.Add(massColor);
GameObject labelGO = Instantiate(labelPrefab, worldCanvas.transform);
TMP_Text label = labelGO.GetComponent<TMP_Text>();
label.text = "";
particles.Add(particle);
labels.Add(label);
particleIDs.Add(nextParticleID++);
}
private void ClampParticlesToBounds()
{
for (int i = 0; i < particles.Count; i++)
{
if (particles[i] == null) continue;
Rigidbody2D rb = particles[i].GetComponent<Rigidbody2D>();
if (rb == null) continue;
Vector2 clampedPos = new Vector2(
Mathf.Clamp(rb.position.x, minBounds.x, maxBounds.x),
Mathf.Clamp(rb.position.y, minBounds.y, maxBounds.y));
if ((clampedPos - rb.position).sqrMagnitude > 0f)
{
rb.position = clampedPos;
rb.velocity = -rb.velocity * 0.5f; // 模擬反彈效果並減速
}
}
}
private void LogParticleStates()
{
System.Text.StringBuilder screenOutput = new System.Text.StringBuilder();
System.Text.StringBuilder fileOutput = new System.Text.StringBuilder();
for (int i = 0; i < particles.Count; i++)
{
GameObject p = particles[i];
if (p != null)
{
Rigidbody2D rb = p.GetComponent<Rigidbody2D>();
if (rb != null)
{
float t = Time.time;
Vector2 pos = rb.position;
Vector2 vel = rb.velocity;
screenOutput.AppendLine($"[{t:F2}s] Particle {i}: Pos=({pos.x:F2},{pos.y:F2}), Vel=({vel.x:F2},{vel.y:F2})");
fileOutput.AppendLine($"{t:F2},{i},{pos.x:F2},{pos.y:F2},{vel.x:F2},{vel.y:F2}");
}
}
}
if (logOutputText != null) logOutputText.text = screenOutput.ToString();
File.AppendAllText(logFilePath, fileOutput.ToString());
if (collisionLog.Count > 0)
{
File.AppendAllLines(logFilePath, collisionLog);
collisionLog.Clear();
}
}
private void LogTotalKineticEnergy()
{
float totalEnergy = 0f;
foreach (var p in particles)
{
if (p == null) continue;
Rigidbody2D rb = p.GetComponent<Rigidbody2D>();
if (rb != null)
totalEnergy += 0.5f * rb.mass * rb.velocity.sqrMagnitude;
}
if (energyValueText != null)
energyValueText.text = $"目前總動能:{totalEnergy:F2}";
energyHistory.Add(totalEnergy);
if (energyHistory.Count > maxEnergyBars)
energyHistory.RemoveAt(0);
UpdateEnergyGraph();
}
private void UpdateEnergyGraph()
{
if (energyGraphContainer == null || energyBarPrefab == null) return;
foreach (Transform child in energyGraphContainer)
{
Destroy(child.gameObject);
}
float graphHeight = energyGraphContainer.rect.height;
float graphWidth = energyGraphContainer.rect.width;
float maxEnergy = Mathf.Max(1f, Mathf.Max(energyHistory.ToArray()));
float barWidth = graphWidth / Mathf.Max(1, energyHistory.Count);
for (int i = 0; i < energyHistory.Count; i++)
{
float normalizedHeight = energyHistory[i] / maxEnergy;
GameObject bar = Instantiate(energyBarPrefab, energyGraphContainer);
RectTransform rt = bar.GetComponent<RectTransform>();
rt.anchorMin = new Vector2(0, 0);
rt.anchorMax = new Vector2(0, 0);
rt.pivot = new Vector2(0, 0);
rt.anchoredPosition = new Vector2(i * barWidth, 0);
rt.sizeDelta = new Vector2(barWidth - 1f, normalizedHeight * graphHeight);
}
}
}
0
Subscribe to my newsletter
Read articles from 郭俊鑫 directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
