Particles are the basic building blocks of Obi. Each particle has the following properties:
The following properties are used by fluid particles only, see fluid blueprints for a detailed description of each one:
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, called renderable positions/orientations. You can access them like this:
Vector3 pos = solver.renderablePositions[actor.solverIndices[0]];
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:
A reference to the ObiActor to which this particle belongs.
The index of this particle in the actor's lists.
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; } } } } }
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:
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; } } }
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:
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); }
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:
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); } } }