Scripting advected particles

Writing custom code for advected particles

In order to safely write custom code to emit/kill/control/modify advected particles, you must either:

  • Subscribe to the ObiSolver component's OnAdvection method. In case you're using the Compute backend, subscribing to this event triggers a GPU readback so that advected particle data is available to CPU code executed during OnAdvection. Right after OnAdvection, CPU data is then sent back to the GPU.

  • Subscribe to the ObiSolver component's OnSimulationEnd method. This allows you to modify advected particle data directly on the GPU using compute shaders, without any readbacks.

Data for the advected particles is stored in 4 arrays in the ObiSolver component:

foamPositions
A 4-component vector containing x,y,z position for each particle in solver space. The fourth component (w) contains the number of fluid neighbors for this particle, this value is overwritten by the simulation every frame.
foamVelocities
A 4-component vector that contains x,y,z velocity for each particle in solver space. The fourth component (w) contains particle buoyancy force, you can set this value yourself.
foamAttributes
A 4-component vector: x = normalized particle lifetime (1 being newborn and 0 being dead), y = aging rate, z = particle size, w = 4 packed floats: spray aging rate, spray drag, drag, and isosurface value. You can use ObiUtils.PackFloatRGBA to pack these 4 floats into a single w value.
foamColors
RGBA color for each particle.

These arrays are always ObiSolver.maxFoamParticles in size. However, not all particles in them are alive and require processing. There's an extra foamCount array that holds the amount of alive foam particles in its fourth entry (foamCount[3]).

You can get a NativeArray that wraps around the data in these arrays by calling AsNativeArray<T>() on them. And when using the Compute backend, you can get their data on a GraphicsBuffer by accesing their computeBuffer public variable.

We will now go over 3 different approaches to modifying advected particle data: in the CPU using raw C#, in the CPU using Burst/Jobs, and in the GPU using compute shaders. To clearly showcase the differences between all methods, our goal will be the same in all 3 examples: kill spray particles by making particles age faster (and die sooner) when they have few fluid neighbors.

Using raw C#

This is by far the slowest method (only recommended if you're dealing with a modest amount of advected particles), but also the easiest if you're unfamiliar with Burst/Jobs multithreading or compute shaders.

using UnityEngine;
using Obi;

[RequireComponent(typeof(ObiSolver))]
public class KillBallisticFoamParticles : MonoBehaviour
{
	void OnEnable()
	{
		GetComponent<ObiSolver>().OnAdvection += KillBallisticFoamParticles_OnAdvection;
	}

	void OnDisable()
	{
		GetComponent<ObiSolver>().OnAdvection -= KillBallisticFoamParticles_OnAdvection;
	}

	private void KillBallisticFoamParticles_OnAdvection(ObiSolver solver)
	{
		// iterate over all advected particles
		for (int i = 0; i < solver.foamCount[3]; ++i)
		{
			// if the particle has fewer than 8 fluid neighbors:
			if (solver.foamPositions[i].w < 8)
			{
				Vector4 attrib = solver.foamAttributes[i];

				// age at a rate of ten times its lifetime in 1 second.
				attrib.y = 10;

				solver.foamAttributes[i] = attrib;
			}
		}
	}
}
	

Using Burst/Jobs

By calling AsNativeArray<T>() on the advected particle data lists, we can pass them to a job to take advantage of multithreading.

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

[RequireComponent(typeof(ObiSolver))]
public class KillBallisticFoamParticles : MonoBehaviour
{
	void OnEnable()
	{
		GetComponent<ObiSolver>().OnAdvection += KillBallisticFoamParticles_OnAdvection;
	}

	void OnDisable()
	{
		GetComponent<ObiSolver>().OnAdvection -= KillBallisticFoamParticles_OnAdvection;
	}

	private void KillBallisticFoamParticles_OnAdvection(ObiSolver solver)
	{
		var job = new KillBallisticJob
		{
			foamPositions = solver.foamPositions.AsNativeArray<float4>(),
			foamAttributes = solver.foamAttributes.AsNativeArray<float4>()
		};
		job.Schedule(solver.foamCount[3], 64).Complete();
	}

	[BurstCompile]
	struct KillBallisticJob : IJobParallelFor
	{
		[ReadOnly] public NativeArray<float4> foamPositions;
		public NativeArray<float4> foamAttributes;

		public void Execute(int i)
		{
			// if the particle has less than 8 fluid neighbors:
			if (foamPositions[i].w < 8)
			{
				float4 attrib = foamAttributes[i];

				// age at a rate of ten times its lifetime in 1 second.
				attrib.y = 10;

				foamAttributes[i] = attrib;
			}
		}
	}
}
	

Using Compute shaders

When using the Compute backend, this is the fastest way to modify advected particles as it doesn't require reading data back to the CPU. Note how the code subscribes to OnSimulationEnd, instead of OnAdvection (which would trigger a readback).

using UnityEngine;
using Obi;

[RequireComponent(typeof(ObiSolver))]
public class KillBallisticFoamParticles : MonoBehaviour
{
	ComputeShader shader;

	void Awake()
	{
		shader = Instantiate(Resources.Load<ComputeShader>("KillBallistic"));
	}

	void OnEnable()
	{
		GetComponent<ObiSolver>().OnSimulationEnd += KillBallisticFoamParticles_OnSimulationEnd;
	}

	void OnDisable()
	{
		GetComponent<ObiSolver>().OnSimulationEnd -= KillBallisticFoamParticles_OnSimulationEnd;
	}

	private void KillBallisticFoamParticles_OnSimulationEnd(ObiSolver solver, float simulatedTime, float substepTime)
	{
		if (solver.backendType == ObiSolver.BackendType.Compute)
		{
			shader.SetBuffer(0, "positions", solver.foamPositions.computeBuffer);
			shader.SetBuffer(0, "attributes", solver.foamAttributes.computeBuffer);
			shader.SetBuffer(0, "foamCount", solver.foamCount.computeBuffer);
			shader.DispatchIndirect(0, solver.foamCount.computeBuffer,0);
		}
	}
}
	

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

#pragma kernel CSMain

StructuredBuffer<float4> positions;
RWStructuredBuffer<float4> attributes;
StructuredBuffer<int> foamCount;

[numthreads(128,1,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
    if((int)id.x >= foamCount[3])
        return;
        
    // if the particle has less than 8 fluid neighbors:
    if (positions[id.x].w < 8)
    {
        float4 attrib = attributes[id.x];

        // die at a rate of ten times its lifetime in 1 second.
        attrib.y = 10;

        attributes[id.x] = attrib;
    }
}