Intro/Core/Algorithm
So far (as of Part 2 of this tutorial series), we’ve been using a double-loop to find particles within some proximity of one another, then drawing lines between them.
By adding another loop into the algorithm (triple-loop!~), we can find triangles.
for (int i = 0; i < n; i++) // Loop A.
for (int j = i + 1; j < n; j++) // Loop B.
if (Distance(p[i], p[j]) < lineDist) // Compare A to B.
for (int k = j + 1; k < n; k++) // Loop C.
if (Distance(p[i], p[k]) < lineDist && // Compare A to C.
Distance(p[j], p[k]) < lineDist) // Compare B to C.
{
// Triangle here!
}
Here’s the algorithm as actual C# code.
Mesh Generation (Setup)
The crux of this part of the Plexus tutorial is the procedural generation of a triangle-filled mesh, using the triple-connected particles found in our algorithm as the vertices.
First, let’s setup the mesh + renderer components in the editor…
Create a new GameObject in Unity.
Add Mesh Filter and Mesh Renderer as components.
Assign any material you’d like for rendering. Ideally, you’ll want to use one that can read vertex colours (most of Unity’s particle system and line shaders can do this).
That’s all for our setup in the editor.
Let’s get into the triangle generation code.
Mesh Generation (Code)
We’ll need to reference our MeshFilter, which will be assigned a procedurally-generated mesh with all our triangles. We’ll need a list of triangle indices and an array for the actual vertices (positions). The indices point to the vertices in our array that form triangles. We can colour + texture our triangles by updating and passing the appropriate additional data arrays.
// Triangles.
Mesh mesh; // Generated triangles mesh.
public MeshFilter meshFilter;
// List of triangle indices.
List<int> meshTriangles = new();
// Mesh data arrays.
Vector3[] meshVertices;
Color[] meshColours;
Vector2[] meshUVs;
In our update loop, we need to make sure our mesh data is initialized. Notice the arrays have a size/length up to as many particles are possible (maxParticleCount
).
// Create and assign mesh if null.
if (mesh == null)
{
mesh = new Mesh();
meshFilter.mesh = mesh;
}
// Clear mesh data.
meshTriangles.Clear();
// Create/resize mesh data arrays.
if (meshVertices == null || meshVertices.Length != maxParticleCount)
{
meshVertices = new Vector3[maxParticleCount];
}
if (meshColours == null || meshColours.Length != maxParticleCount)
{
meshColours = new Color[maxParticleCount];
}
if (meshUVs == null || meshUVs.Length != maxParticleCount)
{
meshUVs = new Vector2[maxParticleCount];
}
Then, up to the actual/current number of particles, we update our vertex array.
// Copy particle positions to vertices.
for (int i = 0; i < particleCount; i++)
{
meshVertices[i] = particles[i].position;
}
This doesn’t have to be in its own loop- you can place it at the start of the first loop (loop ‘i’, of i, j, and k) during the line connection and triangle search triple-loop.
In our third loop (loop ‘k’ of i, j, and k), we can add the indices that form a triangle, along with updating and assigning the correct UVs + colours from particles.
// Find triangles.
for (int k = j + 1; k < particleCount; k++)
{
ParticleSystem.Particle particleC = particles[k];
// Distance: A -> C and B -> C.
// If distance between particles < threshold, we have a triangle.
if (Vector3.Distance(particleA.position, particleC.position) < radius &&
Vector3.Distance(particleB.position, particleC.position) < radius)
{
// Triangle with vertices: Particle A, B, and C (indices- i, j, k).
meshTriangles.Add(i);
meshTriangles.Add(j);
meshTriangles.Add(k);
// UVs and colours.
meshUVs[i] = new Vector2(0.0f, 0.0f);
meshUVs[j] = new Vector2(0.0f, 1.0f);
meshUVs[k] = new Vector2(1.0f, 1.0f);
Color colourC = particleC.GetCurrentColor(particleSystem);
meshColours[i] = colourA;
meshColours[j] = colourB;
meshColours[k] = colourC;
}
}
All that’s left is to assign the data to the mesh.
// Set triangle mesh data.
mesh.Clear(); // Required to avoid 'random' mesh errors.
mesh.SetVertices(meshVertices, 0, particleCount);
// Set triangles AFTER vertices are set, since indices reference vertices.
mesh.SetTriangles(meshTriangles, 0);
mesh.SetColors(meshColours, 0, particleCount);
mesh.SetUVs(0, meshUVs, 0, particleCount);
mesh.RecalculateNormals();
That’s it! ~Make sure to assign the Mesh Filter.
Now you have both lines and triangles, rendered procedurally with property inheritance from connecting particles (line size, colour, vertex colours).
Debug Toggle
You can add/expose public bool debugDrawLines
as a toggle for debug rendering.
Now we can choose to preview lines in the editor, outside of Play mode.
Full Script
Here’s the full/working script for easy copy-pasting:
using UnityEngine;
using System.Collections.Generic;
[ExecuteAlways]
public class ParticlePlexus_Tutorial_Part3 : MonoBehaviour
{
// Particle system and particles array/list.
ParticleSystem particleSystem;
ParticleSystem.Particle[] particles;
// Line distance threshold.
public float radius = 1.0f;
[Space]
// Maximum number of lines to render.
[Range(0, 8192)]
public int maxLineRenderers = 1000;
[Range(0.0f, 1.0f)]
public float lineWidth = 0.2f;
[Space]
// Template for lines connecting particles.
public LineRenderer lineRendererPrefab;
// Line renderer pool.
LineRenderer[] lineRenderers;
// Triangles.
Mesh mesh; // Generated triangles mesh.
public MeshFilter meshFilter;
// List of triangle indices.
List<int> meshTriangles = new();
// Mesh data arrays.
Vector3[] meshVertices;
Color[] meshColours;
Vector2[] meshUVs;
[Space]
public bool debugDrawLines = false;
// Render a line between pA and pB,
// with start/end colours cA and cB.
// n = number of segments to render.
static void DebugDrawLineGradient(
Vector3 pA, Vector3 pB, Color cA, Color cB, uint n)
{
for (uint i = 0; i < n; i++)
{
float t = i / (float)n;
float tNext = (i + 1.0f) / n;
Vector3 a = Vector3.Lerp(pA, pB, t);
Vector3 b = Vector3.Lerp(pA, pB, tNext);
Color c = Color.Lerp(cA, cB, t);
Debug.DrawLine(a, b, c);
}
}
void DestroyAllLineRenderersIfNotNull()
{
if (lineRenderers != null)
{
for (int i = 0; i < lineRenderers.Length; i++)
{
if (lineRenderers[i] != null)
{
DestroyImmediate(lineRenderers[i].gameObject);
}
}
}
}
void OnDestroy()
{
DestroyAllLineRenderersIfNotNull();
}
void LateUpdate()
{
// Get particle system component if null.
if (particleSystem == null)
{
particleSystem = GetComponent<ParticleSystem>();
}
// Initialize particles array if null or size mismatch to max.
int maxParticleCount = particleSystem.main.maxParticles;
if (particles == null || particles.Length != maxParticleCount)
{
particles = new ParticleSystem.Particle[maxParticleCount];
}
// Load particles from system into our array.
int particleCount = particleSystem.GetParticles(particles);
// Create and assign mesh if null.
if (mesh == null)
{
mesh = new Mesh();
meshFilter.mesh = mesh;
}
// Clear mesh data.
meshTriangles.Clear();
// Create/resize mesh data arrays.
if (meshVertices == null || meshVertices.Length != maxParticleCount)
{
meshVertices = new Vector3[maxParticleCount];
}
if (meshColours == null || meshColours.Length != maxParticleCount)
{
meshColours = new Color[maxParticleCount];
}
if (meshUVs == null || meshUVs.Length != maxParticleCount)
{
meshUVs = new Vector2[maxParticleCount];
}
// Compare each particle to every other particle.
int lineRendererCount = 0;
// Create line renderer pool.
// Leaving this in Update allows for resizing maxLines at runtime.
if (Application.isPlaying)
{
if (lineRenderers == null || lineRenderers.Length != maxLineRenderers)
{
// If line renderers already exist, destroy them.
DestroyAllLineRenderersIfNotNull();
// Create new line renderers.
lineRenderers = new LineRenderer[maxLineRenderers];
// Instantiate line renderers from prefab.
// > this transform as parent.
for (int i = 0; i < lineRenderers.Length; i++)
{
lineRenderers[i] =
Instantiate(lineRendererPrefab, transform);
}
}
}
for (int i = 0; i < particleCount; i++)
{
meshVertices[i] = particles[i].position; // Update mesh vertices.
ParticleSystem.Particle particleA = particles[i];
for (int j = i + 1; j < particleCount; j++)
{
ParticleSystem.Particle particleB = particles[j];
// Distance: A -> B.
// If distance between particles < threshold, we have a line/connection.
if (Vector3.Distance(particleA.position, particleB.position) < radius)
{
Color colourA = particleA.GetCurrentColor(particleSystem);
Color colourB = particleB.GetCurrentColor(particleSystem);
if (debugDrawLines)
{
DebugDrawLineGradient(
particleA.position, particleB.position, colourA, colourB, 8);
}
// Find triangles.
for (int k = j + 1; k < particleCount; k++)
{
ParticleSystem.Particle particleC = particles[k];
// Distance: A -> C and B -> C.
// If distance between particles < threshold, we have a triangle.
if (Vector3.Distance(particleA.position, particleC.position) < radius &&
Vector3.Distance(particleB.position, particleC.position) < radius)
{
// Triangle with vertices: Particle A, B, and C (indices- i, j, k).
meshTriangles.Add(i);
meshTriangles.Add(j);
meshTriangles.Add(k);
// UVs and colours.
meshUVs[i] = new Vector2(0.0f, 0.0f);
meshUVs[j] = new Vector2(0.0f, 1.0f);
meshUVs[k] = new Vector2(1.0f, 1.0f);
Color colourC = particleC.GetCurrentColor(particleSystem);
meshColours[i] = colourA;
meshColours[j] = colourB;
meshColours[k] = colourC;
}
}
if (Application.isPlaying)
{
if (lineRendererCount < lineRenderers.Length)
{
LineRenderer lineRenderer =
lineRenderers[lineRendererCount];
lineRenderer.SetPosition(0, particleA.position);
lineRenderer.SetPosition(1, particleB.position);
lineRenderer.startColor = colourA;
lineRenderer.endColor = colourB;
float sizeA =
particleA.GetCurrentSize(particleSystem);
float sizeB =
particleB.GetCurrentSize(particleSystem);
lineRenderer.startWidth = sizeA * lineWidth;
lineRenderer.endWidth = sizeB * lineWidth;
lineRenderer.gameObject.SetActive(true);
lineRendererCount++;
}
}
}
}
}
if (Application.isPlaying)
{
// Disable any remaining line renderers.
for (int i = lineRendererCount; i < lineRenderers.Length; i++)
{
lineRenderers[i].gameObject.SetActive(false);
}
}
// Set triangle mesh data.
mesh.Clear(); // Required to avoid 'random' mesh errors.
mesh.SetVertices(meshVertices, 0, particleCount);
// Set triangles AFTER vertices are set, since indices reference vertices.
mesh.SetTriangles(meshTriangles, 0);
mesh.SetColors(meshColours, 0, particleCount);
mesh.SetUVs(0, meshUVs, 0, particleCount);
mesh.RecalculateNormals();
}
}
Play around with the colours, it can be fun! Enjoy~
You can follow me on Twitter/X for more (@TheMirzaBeig)!