Scripting Particles

Particles are the basic building blocks of Obi. Each particle has the following properties:

Activation flag
A boolean value that indicates if the particle is being updated by the solver or not. Inactive particles are particles that have been optimized out of the simulation, or that have been reserved for later use (when a cloth tears, or a rope increases its length, for instance).
Position
Position of the particles in the actor (in local space).
Rest position
Rest position of the particles in the actor. These are used to ignore self-particle collisions between particles that were already intersecting in the actor's rest configuration.
Orientation
Orientation of the particles in the actor (in local space).
Rest orientation
Rest orientation of the particles in the actor.
Velocity
Particle velocities (in local space).
Angular velocity
Particle angular velocities (in local space).
Inverse mass
Inverse mass for each particle. An inverse mass of 0 means the particle's mass is infinite, so its position will be unaffected by dynamics (allowing you to override it manually).
Inverse rotational mass
Inverse rotational mass for each particle. An inverse mass of 0 means the particle's rotational mass is infinite, so its orientation will be unaffected by dynamics (allowing you to override it manually).
External Forces
External force applied to each particle during a simulation step. This value gets resetted to zero at the end of each simulation step.
External Torques
External torque applied to each particle during a simulation step. This value gets resetted to zero at the end of each simulation step.
Principal radii
Particle radius used for collision detection, in each of the ellipsoid principal axis, sorted in descending magnitude.
Phase
Integer value that is used to determine actor id (less significant bits 0-23, up to 16 million actors), if it's self colliding (bit 24), particle type (bit 25, solid or fluid) or one-sided (bit 26).
Filter
Integer value used for collision filtering. Less significat 16 bits contain a collision category (from 0 to 15) and most significant 16 bits contain a collision mask. See collisions.
Color
Particle color, only used by ObiParticleRenderer and ObiFluidRenderer.

The following properties are used by fluid particles only, see fluid blueprints for a detailed description of each one:

Life
Particle's remaining lifetime in seconds. The particle will die and become inactive when this value is equal to or less than zero.
Fluid Data
A 4-component vector containing basic fluid data for fluid particles: density, lagrange multiplier, local gradient, neighborhood gradient.
User Data
A 4-component vector that contains arbitrary per-particle data set by the user. The data in this array will be affected by diffusion.
Fluid Materials
A 4-component vector: smoothing radius, polarity, viscosity and vorticity for each fluid particle.
Fluid Interface
A 4-component vector: athmospheric drag, athmospheric pressure, buoyancy and diffusion speed for each fluid particle.

Each ObiSolver has a list for each one of these properties (solver.positions, solver.principalRadii, solver.velocities, solver.colors and so on), that stores the particle data for all actors currently being simulated by the solver. All spatial properties (positions, orientations, velocities, vorticities, etc) are expressed in the solver's local space.

Particle position, orientation and linear/angular velocities are usually the only properties you´ll be interested in retrieving, since all other properties are not modified by the solver during simulation. The solver will also generate temporally smoothed particle positions and orientations at the end of every frame in LateUpdate(), called renderable positions/orientations. You can access them like this:

Vector3 pos = solver.renderablePositions[actor.solverIndices[0]];
			

Accessing particle data

Particle indices in an actor run from 0 to the amount of particles in that actor. However, due to the memory allocation strategy used by solvers, data for any given actor may not be stored contiguously in the solver (see architecture): particle data (positions, velocities, etc) belonging to the same actor may be scattered across the solver's data lists. To understand why this is the case, let's take a look at the positions list of an empty solver:


Now, let's add three actors to this solver: a rope made out of 8 particles, a cloth containing 22 particles, and a fluid emitter containing 8 particles. Their particles are assigned the first empty entries the solver can find, so their data ends up being contiguous:


After adding all 3 actors, we decide to destroy the cloth:


Destroying the cloth leaves a gap in the solver's positions list:


If we now add another actor (in this case, a softbody), the solver will allocate the empty entries left by the cloth and use them to store particles belonging to the softbody. If more room for particles is needed after the gap has been completely filled, it will use the next empty entries it can find. As a result, particles #8 to #24 and #33 to #40 end up belonging to the same actor:


As a result of this particle allocation strategy, all actors have a solverIndices array that you can use to map from actor particle index to solver particle index. In the above example, the softbody's solverIndices array would have the following 25 entries:

8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,33,34,35,36,37,38,39,40

// index of the first particle in the actor:
int actorIndex = 0;

// solver index of the first particle in the actor.
int solverIndex = actor.solverIndices[actorIndex];

// use it to get the particle's current velocity.
Vector3 velocity = solver.velocities[solverIndex];
			

You can also do the opposite: given a solver index, find out which actor it belongs to and its original index in the actor:

ObiSolver.ParticleInActor pa = solver.particleToActor[solverIndex];
			

The ParticleInActor struct has two public variables:

Actor

A reference to the ObiActor to which this particle belongs.

IndexInActor

The index of this particle in the actor's lists.

Using raw C# to process particles

The next example is a component that will calculate the center of mass of an actor as the mass-weighted sum of all particle positions, and render it using OnDrawGizmos()

using UnityEngine;
using Obi;

[RequireComponent(typeof(ObiActor))]
public class ActorCOM : MonoBehaviour {

	ObiActor actor;

	void Awake(){
		actor = GetComponent<ObiActor>();
	}

	void OnDrawGizmos(){

		if (actor == null || !actor.isLoaded)
			return;

		Gizmos.color = Color.red;
		Vector4 com = Vector4.zero;
		float massAccumulator = 0;

		// Iterate over all particles in an actor:
		for (int i = 0; i < actor.solverIndices.count; ++i){

			// retrieve the particle index in the solver:
			int solverIndex = actor.solverIndices[i];

			// look up the inverse mass of this particle:
			float invMass = actor.solver.invMasses[solverIndex];

			// accumulate it:
			if (invMass > 0)
			{
				massAccumulator += 1.0f / invMass;
				com += actor.solver.positions[solverIndex] / invMass;
			}

		}

		com /= massAccumulator;
		Gizmos.DrawWireSphere(com,0.1f);
	}
}
			

Here's a sample script to visualize an actor's particle velocities using OnDrawGizmos(). Positions and velocities are expressed in the solver's local space. Since we are interested in drawing the velocities in world space, Gizmos.matrix has been set to the solver's local-to-world matrix.

using UnityEngine;
using Obi;

[RequireComponent(typeof(ObiActor))]
public class VelocityVisualizer : MonoBehaviour {

	ObiActor actor;

	void Awake(){
		actor = GetComponent<ObiActor>();
	}

	void OnDrawGizmos(){

		if (actor == null || !actor.isLoaded)
			return;

		Gizmos.color = Color.red;
		Gizmos.matrix = actor.solver.transform.localToWorldMatrix;

		for (int i = 0; i < actor.solverIndices.count; ++i){
			int solverIndex = actor.solverIndices[i];
			Gizmos.DrawRay(actor.solver.positions[solverIndex],
				       actor.solver.velocities[solverIndex] * Time.fixedDeltaTime);
		}
	}
}
			

The following script fixes in place all particles that are near a given anchor transform.

using UnityEngine;
using System.Collections;
using Obi;

[RequireComponent(typeof(ObiActor))]
public class DistanceAnchor : MonoBehaviour {

	ObiActor actor;
	public Transform anchor;
	public float anchorRadius = 0.5f;

	void Awake(){
		actor = GetComponent<ObiActor>();
	}

	void Start(){
		if (actor.isLoaded){
			for (int i = 0; i < actor.solverIndices.count; ++i){

				int solverIndex = actor.solverIndices[i];

				// if the particle is visually close enough to the anchor, fix it.
				if (Vector3.Distance(actor.GetParticlePosition(solverIndex), anchor.position)
						< anchorRadius)
				{
					actor.solver.velocities[solverIndex] = Vector3.zero;
					actor.solver.invMasses[solverIndex] = 0;
				}
			}
		}
	}
}
            

Using Burst/Jobs to process particles

You can use AsNativeArray<T>() to wrap a NativeArray around any of the solver particle data lists listed at the top of this page (positions, velocities, orientations, etc). Note this doesn't perform a copy of the data, it just wraps a NativeArray around it. This allows you to access particle data efficiently from Unity jobs, and write custom multithreaded code.

Here's an example component that attracts all particles in an actor towards it, by applying a custom acceleration in a Burst-compiled job:

a gravity well implemented using jobs.

using UnityEngine;
using Obi;
using Unity.Burst;
using Unity.Jobs;
using Unity.Collections;
using Unity.Mathematics;

public class GravityWell : MonoBehaviour
{
    public ObiActor actor;
    public float gravityStrength = 1;

    public void Start()
    {
        if (actor != null)
            actor.solver.OnBeginStep += Solver_OnBeginStep;
    }

    public void OnDestroy()
    {
        if (actor != null)
            actor.solver.OnBeginStep -= Solver_OnBeginStep;
    }

    private void Solver_OnBeginStep(ObiSolver solver, float stepTime)
    {
        var indices = new NativeArray<int>(actor.solverIndices, Allocator.TempJob);

        var job = new CustomGravityJob
        {
            indices = indices,
            velocities = actor.solver.velocities.AsNativeArray<float4>(),
            positions = actor.solver.positions.AsNativeArray<float4>(),
            wellPosition = actor.solver.transform.InverseTransformPoint(transform.position),
            gravityStrength = gravityStrength,
            deltaTime = stepTime,
        };

        job.Schedule(indices.Length, 128).Complete();
    }

    [BurstCompile]
    struct CustomGravityJob : IJobParallelFor
    {
        [ReadOnly][DeallocateOnJobCompletion] public NativeArray<int> indices;
        [ReadOnly] public NativeArray<float4> positions;
        public NativeArray<float4> velocities;

        [ReadOnly] public float3 wellPosition;
        [ReadOnly] public float gravityStrength;
        [ReadOnly] public float deltaTime;

        public void Execute(int i)
        {
            var index = indices[i];
            var vel = velocities[index];
            vel.xyz += math.normalizesafe(wellPosition - positions[index].xyz) * gravityStrength * deltaTime;
            velocities[index] = vel;
        }
    }
}

Using Compute shaders to process particles

When using the Compute backend, you can access the computeBuffer property of any of the solver particle data lists listed at the top of this page (positions, velocities, orientations, etc). This returns a GraphicsBuffer with the contents of the array, which you can then pass to a compute shader to perform any custom calculations.

Here's an example component that uses a color ramp texture to set the color of particles according to their velocity, using a custom compute shader:

Fluid colored using a custom compute shader.

using UnityEngine;
using Obi;

[RequireComponent(typeof(ObiSolver))]
public class ColorFromVelocity : MonoBehaviour
{
	ComputeShader shader;
	public Texture2D colorRamp;

	[Min(0.0001f)]
	public float maxVelocity = 5.0f;
	
	void Awake()
	{
		shader = Instantiate(Resources.Load<ComputeShader>("ColorFromVelocity"));
		GetComponent<ObiSolver>().OnInterpolate += ColorFromVelocity_OnInterpolate;
	}

	private void ColorFromVelocity_OnInterpolate(ObiSolver solver, float simulatedTime, float substepTime)
	{
		if (solver.backendType == ObiSolver.BackendType.Compute)
		{
			shader.SetTexture(0, "ColorRamp", colorRamp);
			shader.SetBuffer(0, "velocities", solver.velocities.computeBuffer);
			shader.SetBuffer(0, "colors", solver.colors.computeBuffer);

			int threadGroups = ComputeMath.ThreadGroupCount(solver.allocParticleCount, 128);
			shader.SetInt("particleCount", solver.allocParticleCount);
			shader.SetFloat("maxVelocity", maxVelocity);
			shader.Dispatch(0,threadGroups,1,1);
		}
	}
}

And the corresponding compute shader, that must be placed in a /Resources folder:

#pragma kernel CSMain

StructuredBuffer<float4> velocities;
RWStructuredBuffer<float4> colors;

Texture2D<float4> ColorRamp;
SamplerState samplerColorRamp;

uint particleCount;
float maxVelocity;

[numthreads(128,1,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
	if(id.x >= particleCount)
		return;
		
	float speed = length(velocities[id.x]) / maxVelocity;
	colors[id.x] = ColorRamp.SampleLevel(samplerColorRamp, float2(speed,0.5f), 0);
}

Modifying data in the CPU when using the Compute backend

When using the Compute backend it is also possible to modify particle data in the CPU (using either raw C# or Burst/Jobs) without the need to write a compute shader, this however requires to synchronize data between the CPU and the GPU.

Obi will automatically upload particle data to the GPU at the start of every simulation step (only those data lists that have been modified by the CPU since the last time they were uploaded). However, it will only automatically read positions and velocities back from the GPU at the end of each step for performance reasons. If you want to read any other data from the CPU (eg. colors or user data) you need to manually ask Obi to read this data back from the GPU. Note this is also necessary if you're going to modify this data on the CPU, otherwise you would be working with stale data.

Readbacks in Obi are an asynchronous operation. To start reading data back from the GPU, call Readback() on the desired particle data list. You can then use WaitForReadback() to make the CPU wait for the current readback to be complete.

It is advisable to call WaitForReadback() as far away from Readback() as possible, to avoid long waits while the requested data travels from GPU to CPU. Ideally, Readback() should be called right after a simulation step has been dispatched to the GPU, and WaitForReadback() right after a step has been completed. ObiSolver provides two callback events for this purpose: OnRequestReadback and OnSimulationEnd. ObiActors expose two virtual methods for the same purpose: RequestReadback() and SimulationEnd().

Here's an example component that drives particle color using density on the CPU while using the Compute backend:

Colors are driven by density, using a rainbow gradient from blue (low density) to red (high density).

using UnityEngine;
using Obi;

[RequireComponent(typeof(ObiEmitter))]
public class ColorFromDensity: MonoBehaviour
{
	ObiEmitter emitter;
	public Gradient grad;

	void Awake()
	{
		emitter = GetComponent<ObiEmitter>();

		emitter.OnBlueprintLoaded += Emitter_OnBlueprintLoaded;
		emitter.OnBlueprintUnloaded += Emitter_OnBlueprintUnloaded;

		if (emitter.isLoaded)
			Emitter_OnBlueprintLoaded(emitter, emitter.sourceBlueprint);
	}

	private void OnDestroy()
	{
		emitter.OnBlueprintLoaded -= Emitter_OnBlueprintLoaded;
		emitter.OnBlueprintUnloaded -= Emitter_OnBlueprintUnloaded;
	}

	private void Emitter_OnBlueprintLoaded(ObiActor actor, ObiActorBlueprint blueprint)
	{
		actor.solver.OnRequestReadback += Solver_OnRequestReadback;
		actor.solver.OnSimulationEnd += Solver_OnSimulationEnd;
	}

	private void Emitter_OnBlueprintUnloaded(ObiActor actor, ObiActorBlueprint blueprint)
	{
		actor.solver.OnRequestReadback -= Solver_OnRequestReadback;
		actor.solver.OnSimulationEnd -= Solver_OnSimulationEnd;
	}

	private void Solver_OnRequestReadback(ObiSolver solver)
	{
		// request fluidData back from GPU:
		solver.fluidData.Readback();
	}

	private void Solver_OnSimulationEnd(ObiSolver solver, float simulatedTime, float substepTime)
	{
		// wait for fluid data to arrive from the GPU:
		solver.fluidData.WaitForReadback();

		// calculate number of dimensions (2 in 2D mode, 3 in 3D mode). We'll use this
		// to approximate particle volume using its radius.
		int dimensions = 3 - (int)solver.parameters.mode; 

		for (int i = 0; i < emitter.solverIndices.count; ++i)
		{
			int k = emitter.solverIndices[i];

			var density = emitter.solver.fluidData[k].x;
			var volume = Mathf.Pow(emitter.solver.principalRadii[k].x * 2, dimensions);

			// calculate normalized density from 0 to 1, use that to drive color. 
			// Modifying the colors list will flag it to be automatically uploaded to the GPU at the start of the next step.
			emitter.solver.colors[k] = grad.Evaluate(density * volume - 1);
		}
	}

}