In this page we will go over some scripting examples that take advantage of custom user data associated to fluid and granular particles, and how diffusion averages it for neighboring particles.
We could make use of collision callbacks to change the diffusion data of particles in contact with a specific collider, then let diffusion propagate this value to nearby particles.
This is a good way to model heat diffusion: we use one diffusion channel to store temperature, and set a high temperature when particles come into contact with a collider. We can then map temperature to viscosity to get thinner, runny fluid when it's hot, and thicker, more viscous fluid when it's cold. You can see this in action in the included "Raclette" sample scene:
We'll use the first diffusion channel to store temperature. First, we need to subscribe to collision callbacks. Every frame we will check for contacts between colliders and particles, and increase (or decrease) their temperature depending on which collider they are in contact with:
using UnityEngine; using Obi; [RequireComponent(typeof(ObiSolver))] public class Melt : MonoBehaviour { public float heat = 0.1f; public float cooling = 0.1f; public ObiCollider hotCollider = null; public ObiCollider coldCollider = null; private ObiSolver solver; void Awake(){ solver = GetComponent<Obi.ObiSolver>(); } void OnEnable () { solver.OnCollision += Solver_OnCollision; } void OnDisable(){ solver.OnCollision -= Solver_OnCollision; } void Solver_OnCollision (object sender, Obi.ObiSolver.ObiCollisionEventArgs e) { var colliderWorld = ObiColliderWorld.GetInstance(); for (int i = 0; i < e.contacts.Count; ++i) { if (e.contacts.Data[i].distance < 0.001f) { var col = colliderWorld.colliderHandles[e.contacts.Data[i].bodyB].owner; if (col != null) { int k = e.contacts.Data[i].bodyA; Vector4 userData = solver.userData[k]; if (col == hotCollider){ userData[0] = Mathf.Max(0.05f,userData[0] - heat * Time.fixedDeltaTime); }else if (col == coldCollider){ userData[0] = Mathf.Min(10,userData[0] + cooling * Time.fixedDeltaTime); } solver.userData[k] = userData; } } } } }
Then, we initialize the contents of the userData array (temperature, in our case) to viscosity when particles are first emitted. We also need to map temperature to viscosity at the end of each frame:
using UnityEngine; using Obi; [RequireComponent(typeof(ObiEmitter))] public class ViscosityFromTemperature : MonoBehaviour { void Awake() { GetComponent<ObiEmitter>().OnEmitParticle += Emitter_OnEmitParticle; } void Emitter_OnEmitParticle (ObiEmitter emitter, int particleIndex) { if (emitter.solver != null) { int k = emitter.solverIndices[particleIndex]; Vector4 userData = emitter.solver.userData[k]; userData[0] = emitter.solver.viscosities[k]; emitter.solver.userData[k] = userData; } } void LateUpdate() { for (int i = 0; i < emitter.solverIndices.Length; ++i){ int k = emitter.solverIndices[i]; emitter.solver.viscosities[k] = emitter.solver.userData[k][0]; } } }
Here's the result: the initially thick, viscous fluid falls on a hot plate, becomes thinner and flows to a cold plate, where it becomes thicker again. We also mapped viscosity to color to make it easier to visualize:
We'll go over one more example using the Compute backend and compute shaders. It's a 2D toy/sandbox game with many different fluid and granular materials that can interact with each other, based on their temperature, ambient transfer rate and humidity stored in 3 user data channels. We will use the fourth user data channel to store a materialType integer per particle, and will set the solver's diffusionMask to 1,1,1,0 to keep the materialType unaffected by diffusion.
The bulk of the work happens in a compute shader that:
Here's how it looks:
#pragma kernel PhaseChanges #include "../../../../Resources/Compute/MathUtils.cginc" #include "../../../../Resources/Compute/Phases.cginc" struct ToyMaterial { float4 color; float4 hotColor; float4 fluidMaterial; float4 fluidMaterial2; float4 fluidInterface; }; StructuredBufferactiveParticles; StructuredBuffer toyMaterials; RWStructuredBuffer positions; RWStructuredBuffer principalRadii; RWStructuredBuffer userData; RWStructuredBuffer colors; RWStructuredBuffer life; RWStructuredBuffer invMasses; RWStructuredBuffer fluidMaterials; RWStructuredBuffer fluidMaterials2; RWStructuredBuffer fluidInterface; RWStructuredBuffer phases; uint particleCount; float randomSeed; float deltaTime; [numthreads(128,1,1)] void PhaseChanges (uint3 id : SV_DispatchThreadID) { if(id.x >= particleCount) return; int p = activeParticles[id.x]; // read particle data: float4 user = userData[p]; float4 color = colors[p]; float4 fluidMat = fluidMaterials[p]; float4 fluidMat2 = fluidMaterials2[p]; float4 fluidIf = fluidInterface[p]; float temperature = user.x; float ambientTransferRate = user.y; float humidity = user.z; int materialType = user.w; // skip particles with no phase change: if (materialType <= 0 || materialType > 12) return; // update color due to temperature: color = lerp(toyMaterials[materialType].color, toyMaterials[materialType].hotColor, saturate(temperature / 50)); // humidity is reduced as temperature increases: humidity -= temperature * 0.1 * deltaTime; // dissipate temperature: temperature -= sign(temperature) * max(0,ambientTransferRate) * deltaTime; // sand turns into mud when humid: if (materialType == 1 && humidity > 0.5) { materialType = 5; humidity = 2; fluidMat = toyMaterials[materialType].fluidMaterial; phases[p] |= Fluid; } // snow becomes water at high temperatures: if (materialType == 2 && temperature > 5) { materialType = 3; humidity = 10; fluidMat = toyMaterials[materialType].fluidMaterial; phases[p] |= Fluid; } // water turns to smoke at high temperatures, disappears in contact with moss. if (materialType == 3) { if (temperature > 30) { materialType = 7; humidity = 3; fluidMat = toyMaterials[materialType].fluidMaterial; fluidMat2 = toyMaterials[materialType].fluidMaterial2; fluidIf = toyMaterials[materialType].fluidInterface; if (life[p] > 10) life[p] = 10; } if (ambientTransferRate < 0) { life[p] = 0; } } // mud turns into sand when dry: if (materialType == 5 && humidity <= 0.5) { materialType = 1; humidity = 0; fluidMat = toyMaterials[materialType].fluidMaterial; phases[p] &= ~(Fluid); } // slime behaves like water at high temperatures: if (materialType == 6) { fluidMat = lerp(toyMaterials[materialType].fluidMaterial, toyMaterials[3].fluidMaterial, saturate(temperature / 20)); } // fire disappears when humidity is high. if (materialType == 8 && humidity > 1) { life[p] -= deltaTime * 50; } // lava hardens into stone when cold. if (materialType == 9 && temperature < 10) { materialType = 11; humidity = 0; invMasses[p] = 0; fluidMat = toyMaterials[materialType].fluidMaterial; phases[p] &= ~(Fluid); } // wood ignites (start increasing its own temperature) at high temperatures. if (materialType == 10 && temperature > 20) { temperature += 20 * deltaTime; // when burnt, fall down. if (temperature > 80) { invMasses[p] = 40; color = float4(0.3,0.3,0.3,1); } } // moss disappears at hight temperatures: if (materialType == 12 && temperature > 3) { if (life[p] > 10) life[p] = 0.2; } // write data back: user.x = min(100, temperature); user.z = humidity; user.w = materialType; userData[p] = user; fluidMaterials[p] = fluidMat; fluidMaterials2[p] = fluidMat2; fluidInterface[p] = fluidIf; colors[p] = color; }
Here's is the C# component class that sets up and dispatches the above compute shader at the right time during simulation:
using System.Collections.Generic; using UnityEngine; namespace Obi.Samples { [RequireComponent(typeof(ObiSolver))] public class ToyPhaseChanges : MonoBehaviour { public ComputeShader phaseChangeShader; public ObiEmitter[] emitters; private GraphicsBuffer toyMaterials; private ObiSolver solver; struct ToyMaterial { public Color color; // color when the particle is cold public Color hotColor; // color when the particle is hot public Vector4 fluidMaterial; public Vector4 fluidMaterial2; public Vector4 fluidInterface; }; void OnEnable() { solver = GetComponent<ObiSolver>>(); solver.OnSimulationStart += Solver_OnSimulationStart; solver.OnSubstepsStart += Solver_OnSubstepsStart; solver.OnRequestReadback += Solver_OnRequestReadback; } void OnDisable() { solver.OnSimulationStart -= Solver_OnSimulationStart; solver.OnSubstepsStart -= Solver_OnSubstepsStart; solver.OnRequestReadback -= Solver_OnRequestReadback; } private void InitializeMaterialsList() { if (toyMaterials == null) { List<ToyMaterial> matList = new List<ToyMaterial>(); // add dummy material to account for material-less particles (materialType = 0) matList.Add(new ToyMaterial()); // store reference material data for each emitter: foreach (ObiEmitter emitter in emitters) { if (emitter != null) { var shape = emitter.GetComponent<ObiEmitterShape>(); var em = emitter.GetComponent<ObiEmitter>(); matList.Add(new ToyMaterial { color = shape.color, hotColor = emitter.hotColor, fluidMaterial = em.emissionData.fluidMaterial, fluidMaterial2 = em.emissionData.fluidMaterial2, fluidInterface = em.emissionData.fluidInterface }); } } // ToyMaterial is 80 bytes in size: toyMaterials = new GraphicsBuffer(GraphicsBuffer.Target.Structured, matList.Count, 80); toyMaterials.SetData(matList.ToArray()); } } private void OnDestroy() { toyMaterials.Dispose(); toyMaterials = null; } private void Solver_OnSimulationStart(ObiSolver solv, float timeToSimulate, float substepTime) { InitializeMaterialsList(); solver.colors.WaitForReadback(); solver.userData.WaitForReadback(); solver.invMasses.WaitForReadback(); solver.fluidMaterials.WaitForReadback(); solver.fluidMaterials2.WaitForReadback(); solver.fluidInterface.WaitForReadback(); solver.principalRadii.WaitForReadback(); solver.phases.WaitForReadback(); } private void Solver_OnSubstepsStart(ObiSolver solv, float timeToSimulate, float substepTime) { if (solver.backendType == ObiSolver.BackendType.Compute && solver.activeParticles.computeBuffer != null) { int threadGroups = ComputeMath.ThreadGroupCount(solver.activeParticleCount, 128); phaseChangeShader.SetInt("particleCount", solver.activeParticleCount); phaseChangeShader.SetFloat("deltaTime", timeToSimulate); phaseChangeShader.SetFloat("randomSeed", Time.frameCount % 16535 + Random.value); phaseChangeShader.SetBuffer(0, "toyMaterials", toyMaterials); phaseChangeShader.SetBuffer(0, "activeParticles", solver.activeParticles.computeBuffer); phaseChangeShader.SetBuffer(0, "userData", solver.userData.computeBuffer); phaseChangeShader.SetBuffer(0, "colors", solver.colors.computeBuffer); phaseChangeShader.SetBuffer(0, "invMasses", solver.invMasses.computeBuffer); phaseChangeShader.SetBuffer(0, "fluidMaterials", solver.fluidMaterials.computeBuffer); phaseChangeShader.SetBuffer(0, "fluidMaterials2", solver.fluidMaterials2.computeBuffer); phaseChangeShader.SetBuffer(0, "fluidInterface", solver.fluidInterface.computeBuffer); phaseChangeShader.SetBuffer(0, "phases", solver.phases.computeBuffer); phaseChangeShader.SetBuffer(0, "life", solver.life.computeBuffer); phaseChangeShader.Dispatch(0, threadGroups, 1, 1); } } private void Solver_OnRequestReadback(ObiSolver solv) { if (solver.backendType == ObiSolver.BackendType.Compute) { solver.colors.Readback(); solver.userData.Readback(); solver.invMasses.Readback(); solver.fluidMaterials.Readback(); solver.fluidMaterials2.Readback(); solver.fluidInterface.Readback(); solver.principalRadii.Readback(); solver.phases.Readback(); } } } }