Creation Platform 101: Building a Particle System with Creation
Note: This tutorial comes with full source code that can be found here: https://github.com/frenchdog/CreationPlatform101.git
Now that you know how to build a dependency graph, we will build on what you learned and create a more interactive particle system. We will design a simple system that will allow us to move an emitter and play with some of the parameters using a graphical user interface. We will use the Euler method to solve the motion of our particles. This system will use an openGL viewport to visualize the particles and widgets to edit various parameters like the emission rate and the randomization of the velocity.
Here is a list of the objects we need to design:
- Emitters: Generate initial particle attributes like velocity or position. The emitters can be of various shape e.g a disc, sphere etc
- Forces: Will modify particle velocity. Can be as simple as a directional force, or something more complex, such as a field.
- Simulator: Generates particles and computes their positions for each time step.
The SceneGraphNode class:
This is the main building block in Creation. This node lets you package various DG nodes needed for one specific task. You can connect SceneGraphNodes together using the reference system. Referencing a node is not the same thing as connecting two DGNodes together – which is what we covered in the first tutorial using setDependency() – referencing a SceneGraphNode gives you access to all the DGNodes of the referenced node. You can access every DGNode of a referenced SceneGraphNode, but some dependencies may not make sense. For example, connecting the rotation of a Transform Node to the point positions of a Geometry node would return an error. The node referencing system ensures that only valid connections between SceneGraphNodes are established. Using this system, you can build very complex graphs yet keep things nicely organised – as you can see by opening the SceneGraph Debugger:
Compared to the true Dependency Graph that you can visualize using the Dependency Graph Debugger :
The SceneGraph is an abstraction of the Core dependency graph that lets you construct your scene much more easily. The SceneGraph only exists within Creation Platform, it is not represented in the Core (since it is an abstraction of the `true`dependency graph).
To create a new node you can either derive it from the SceneGraphNode class or use a subclass of SceneGraphNode (like PolygonMesh or PointCloud). These classes create the DGNodes and data members needed for these primitives. You can also augment an existing class by applying a Component. A Component can add additional DGNodes, Operators and References to an existing SceneGraphNode (we will build our particle system node using this approach).
Choosing to subclass a SceneGraphNode or to apply a new Component is a matter of choice that can vary per project. In the particle system that we are building we will create a new SceneGraphNode type for the emitter. You will see that this approach means that we will only be able to connect this type of node to the emitter input of our particle system.
Now that you understand the difference between the Core Dependency Graph and the Creation SceneGraph lets build our particle system!
The Force class:
We will define a simple force here. This shows us how to create a single DGNode in Creation Platform.
class Force(SceneGraphNode):
""" A simple Directional Force Class """
def __init__(self, scene, **options):
super(Force, self).__init__(scene, **options)
self.constructDGNode()
self.getDGNode().addMember( 'direction', 'Vec3', options.setdefault('direction',Vec3(1, 0, 0)) )
self._addMemberInterface(self.getDGNode(), 'direction', True)
self.getDGNode().addMember('intensity', 'Scalar', options.setdefault('intensity',10))
self._addMemberInterface(self.getDGNode(), 'intensity', defineSetter = True)
“constructDGNode(‘myName’)” build a … DGNode inside our SceneGraphNode. But this method doesn’t just call FabricCoreClient.DG.createNode() like we saw in the first article, it is also adding a “getMyNameDGNode” instance method to any instances of the class. Since we didn’t provide a name, the instance method is just called “getDGNode”. But when a SceneGraphNode uses more than one DGNode, it makes sense to use more explicit names for every constructed DGNode.
After we add a member to a DGNode, we can expose data contained in it on our class using “_addMemberInterface”. An Interface is a system that lets you get and (optionally set) a DGNode member from your python code AND from the graphical user interface of the Creation application. This reflection between your python code the running application is very useful as you can inspect any exposed DG members from the UI.
You maybe already noticed that every SceneGraphNode’s “__init__” method uses **options as one of its argument. This let you pass any number of arguments to your subclass using keyword arguments (like kwarg=value). For example, if I use “options['direction']“ in the class object, it will return a value only if I create my class instance like this : force = Force( direction=Vec3(0,5,0) ). If ‘direction’ is not passed in the instance, its construction will fail. This is why we use options.setdefault( ‘direction’, Vec3(10,0,0) ). It will return Vec3(0,5,0) if it is passed as an argument, else it will use the default value Vec3(10,0,0).
The Emitter class:
An emitter will generate some points. Those points will be added to our simulated point cloud each time an emitter create new ones. Creation provides a class called Points, and it provides all the services we need to display our points in the viewport. By inheriting from this class, the Emitter class will just need to define the ‘shape’ of those points positions.
First, we are going to create an ‘abstract’ base class for our emitter. The idea is to be able to connect various type of emitters to our particle system. So if we tell this system to reference any SceneGraphNode of type “BaseParticleEmitter”, it should validate the connection for and only for every BaseParticleEmitter subclasses.
- Note that we didn’t create a Base class for the Force node, but you should also create one if you want to use more than one type of Force in your simulation -
class BaseParticleEmitter(Points):
""" A simple emitter system to define initial values for a particle system"""
def __init__(self, scene, **options):
# ensure that base class is never instantiated
if self.__class__.__name__ == 'BaseParticleEmitter':
raise FabricEngine.CreationPlatform.SceneGraphException('You cannot instantiate the BaseParticleEmitter node directly.')
# call the baseclass constructor
super(BaseParticleEmitter, self).__init__(scene, **options)
self.addValue('points_count', 'Size', options.setdefault('pointCount', 100), addGetterSetterInterface=True)
self.addValue('initial_velocity', 'Vec3', Vec3(0,10,0), addGetterSetterInterface=True)
self.addValue('initial_velocity_variance', 'Vec3', Vec3(1.0, 0.0, 1.0), addGetterSetterInterface=True)
def __setTimeNode(data):
timeController = data['node']
self.getGeometryDGNode().setDependency( timeController.getDGNode(), 'time')
self.addReferenceInterface(name='Time', cls=BaseTime, isList=False, changeCallback=__setTimeNode)
if options.setdefault('time', None) is not None:
self.setTimeNode(options['time'])
# the Time class doesn't store the fps in its DG node. It is a standard python attribute that we set into the emitter node
fps = self.getTimeNode().getFPS()
self.addValue('fps', 'Scalar', fps)
As we are inheriting from the Points class, we can use some handy methods like “addValue()” and “addAttribute()”. The “addValue()” method lets you add a uniform data member to the node (a single value of any supported type) and ”addAttribute()” lets you add some geometry attributes for each point. Currently attributes only support the type Scalar, so to define a Vec3 per point, you just specify the number of components you want in the “addAttribute()” method – note that this is a temporary limitation as more complex types will be handled by the GeometryAttributes in the future.
Using the “addReferenceInterface()” method, we can set a reference to another node, in this case the scene Global Time Node, that is created by the Creation Platform application (as we will see later on). Setting the reference creates a connection between the Particle Emitter and the Time node. But it doesn’t create any dependency with Time’s DGNode(s). You must pass a callback function to addReferenceInterface() that will set the dependency on one or more DGNode. This callback will be called when you “set the reference”. Here, we named this reference “Time” so we can set it using the instance method named ‘setTimeNode’. The ‘setTimeNode’ argument can only by of type ‘Time’, trying to connect another class will raise an error like this :
“FabricEngine.CreationPlatform.SceneGraphException: Reference ‘Time’ only supports nodes of type ‘BaseTime’”
Next we will implement a concrete class for one of our particle emitters. We will create a disc emitter, and it will include its own manipulator to be placed interactively in a 3D view.
class DiscEmitter(BaseParticleEmitter):
""" A specialized Particle Emitter using a circle primitive for interactive manipulation """
def __init__(self, scene, **options):
# call the baseclass constructor
super(DiscEmitter, self).__init__(scene, **options)
self.addValue('seed', 'Integer', 123, addGetterSetterInterface = True)
# add a manipulator
self.circle = LinesCircle(scene, radius=1.0)
self.circleXform = Transform(scene, globalXfo=Xfo(Vec3(0.0,0.0,0.0)))
self.circleMaterial = Material(scene, xmlFile='FlatLinesMaterial')
self.circleMaterial.addPreset(name='blueLines', color=Color(0.35,0.75,0.95), lineWidth = 1.0)
self.circleInstance = GeometryInstance(scene,
geometry=self.circle,
transform=self.circleXform,
material=self.circleMaterial
)
def onConnectCircleXform(data):
circleXform = data['node']
self.getGeometryDGNode().setDependency( circleXform.getDGNode(), 'transform')
self.addReferenceInterface('Transform', Transform, False, onConnectCircleXform)
self.setTransformNode(options.setdefault('emitterXform', self.circleXform))
self.bindDGOperator(self.getGeometryDGNode().bindings,
name = 'DiscEmitterGenerator',
fileName = FabricEngine.CreationPlatform.buildAbsolutePath('ParticleEmitters.kl'),
layout = [
'self.attributes',
'time.time',
'self.fps',
'self.points_count',
'self.seed',
'self.initial_velocity',
'self.initial_velocity_variance',
'transform.localXfo'
],
)
The disc emitter will emit some points randomly into a unit circle, so we define a “seed” value that will be exposed in the UI to let the user edit the random distribution. Then we add a Circle that is a built in primitive in Creation. The circle will help visualize the emitter limits. As we want to interactively move this circle, we add it to an Instance node. The Instance node combines a Transform and a Geometry node to represent a single Instance of a shape at a given position in space. We set a reference with the Transform node as we will need it to move the created particles. Then we can create our first KL operator. If you remember correctly, in the first part we learned that we need to instantiate a DGNode, a Binding and an Operator to then glue those objects together. In Creation Platform it is simpler as you can use the “bindDGOperator()” method instead. You just need to define the DGNode you want to attach the operator to, the name of the KL operator you want to use, the path to the KL file and the Parameter Layout.
In this layout, ‘self’ always refer to the node used in the first argument. ‘transform’ can be also used as we set a reference to this node.
And now the KL code for our emitter :
operator DiscEmitterGenerator(
io GeometryAttributes attributes,
in Scalar time,
in Scalar fps,
in Size pointCount,
in Integer seed,
in Vec3 initialVelocity,
in Vec3 initialVelocityVariance,
in Xfo emitterLocalXfo
) {
attributes.addStandardAttributes( true, true );
attributes.resize(pointCount);
AttributeKey velocityAttrKey = attributes.getKey("velocities");
if(velocityAttrKey.type == AttributeType_Invalid)
velocityAttrKey = attributes.addScalarAttribute("velocities", 3);
Integer offset = fps*time + seed;
executeParallel(
setParticleOnDisc_perPoints,
pointCount, offset,
attributes.scalarAttributes[Attribute_Pos],
attributes.scalarAttributes[velocityAttrKey.index],
initialVelocity,
initialVelocityVariance,
emitterLocalXfo
);
}
GeometryAttributes is a type that let you access a specific attribute using its name (the attribute key). You can resize it and all of its attributes will be resized accordingly. For example, if we want 100 particles then we set attributes.resize( 100 ) and it will set the size for the velocities array to 300 (we set the number of components for the velocities attribute to three).
As we are executing the ”setParticleOnDisc_perPoints()” function in parallel, this function is using an integer as its first argument. In the implementation of the function, it will be the current index in the array of data we want to evaluate. When we call the function, this integer define the size of the array.
The KL code for the ”setParticleOnDisc_perPoints” :
function setParticleOnDisc_perPoints(
in Size index,
in Integer offset,
io ScalarAttribute positionsAttr,
io ScalarAttribute velocitiesAttr,
in Vec3 initialVelocity,
in Vec3 initialVelocityVariance,
in Xfo emitterLocalXfo
){
Index i = 0;
Vec3 position;
do {
position = Vec3( mathRandomScalar(index, offset + i) - 0.5, 0.0, mathRandomScalar(index, offset + i + 500) - 0.5 );
i+=1;
} while (position.lengthSquared() > 0.25 & i < 1000);
Index voff = index * 3;
position *= 2;
position = emitterLocalXfo.transformVector( Vec3(position) );
positionsAttr.setVec3Value(voff, Vec3(position.x, position.y, position.z));
Vec3 rndInitVelocity = initialVelocity;
rndInitVelocity.x += (mathRandomScalar(index, 123) - 0.5) * initialVelocityVariance.x;
rndInitVelocity.y += (mathRandomScalar(index, 345) - 0.5) * initialVelocityVariance.y;
rndInitVelocity.z += (mathRandomScalar(index, 456) - 0.5) * initialVelocityVariance.z;
rndInitVelocity = emitterLocalXfo.ori.rotateVector( Vec3(rndInitVelocity) );
velocitiesAttr.setVec3Value(index * 3, rndInitVelocity);
}
This function generates a random position in a circle. This position is then transformed in the space of the Circle (used by the manipulator). Next, the attribute position is set using this Vec3 value and finally the velocity is set.
The SimulatedParticleComponent class:
This is the central station of our particle simulation. We will use a SimulatedPoints type as this class provide various attributes used in simulation like the velocity. However, in this case we won’t create a new type as it will be used as a standard simulated points node by any SceneGraphNode referencing it. For this scenario, adding a Component is the best option.
You can apply a Component to a SceneGraphNode like that : mySceneGraphNode.addComponent( myComponent( someOptions ) )
The “addComponent()” method of a SceneGraphNode will add a reference to this SceneGraphNode in the instantiated Component. This way the component can take the control of the node and can add any needed feature. To access the node from the component class, you can use self._node.
Let’s take a look at the added feature applied by the “SimulatedParticleComponent” to build our particle system:
class SimulatedParticleComponent(Component):
""" A component to add a simple euler integration solver on a SimulatedPoints node"""
def __init__(self, **options):
super(SimulatedParticleComponent, self).__init__(**options)
@staticmethod
def canApplyTo(node):
return isinstance(node, SimulatedPoints)
def apply(self, points):
super(SimulatedParticleComponent, self).apply(points)
self._node.addValue('gravity', 'Vec3', Vec3(0.0, -9.81, 0.0), True)
self._node.addValue('ageLimit', 'Scalar', 3.0, True)
self._node.addValue('randomizeAgeLimit', 'Scalar', 0.25, True)
self._node.setValueInterfaceOption('randomizeAgeLimit', 'uiRange', Vec2(0, 1))
self._node.addValue('startColor', 'Color', Color(0.0, 0.2, 0.8), True)
self._node.addValue('endColor', 'Color', Color(0.0, 0.0, 0.0), True)
self._node.addValue('randomizeColor', 'Scalar', 0.25, True)
self._node.setValueInterfaceOption('randomizeColor', 'uiRange', Vec2(0, 1))
self._node.addReferenceInterface(name='Emitter', cls=BaseParticleEmitter, isList=False, changeCallback=self.__onChangeEmitterCallback)
self._node.setEmitterNode(self._getOption('emitter'))
self._node.addReferenceInterface(name='Force', cls=Force, isList=False, changeCallback=self.__onChangeForceCallback)
self._node.setForceNode(self._getOption('force'))
# Operators bindings
self._node.bindDGOperator(self._node.getGeometryDGNode().bindings,
name = 'SimulatedParticleOp',
fileName = FabricEngine.CreationPlatform.buildAbsolutePath('SimulatedParticleComponent.kl'),
layout = [
'self.attributes',
'time.time',
'time.timeStep',
'emitter.attributes',
'self.gravity',
'force.direction',
'force.intensity'
])
self._node.bindDGOperator(self._node.getGeometryDGNode().bindings,
name = 'SetParticleColorOp',
fileName = FabricEngine.CreationPlatform.buildAbsolutePath('SimulatedParticleComponent.kl'),
layout = [
'self.attributes',
'self.ageLimit',
'self.randomizeAgeLimit',
'self.randomizeColor',
'self.startColor',
'self.endColor'
])
def __onChangeEmitterCallback(self, data):
emitter = data['node']
self._node.getGeometryDGNode().setDependency( emitter.getGeometryDGNode(), 'emitter')
def __onChangeForceCallback(self, data):
force = data['node']
self._node.getGeometryDGNode().setDependency( force.getDGNode(), 'force')
As you can see, velocities and gravity data is added to our PointCloud. References to the Time, Emitter and Force nodes are set and a “SimulatedParticleOp” operator is added.
Here is the KL code for this operator:
function addPoints(
in Scalar time,
io GeometryAttributes attributes,
in GeometryAttributes emitterAttributes,
in AttributeKey velocityAttrKey,
in AttributeKey emitterVelocityAttrKey
) {
if(time==0) {
attributes.resize(emitterAttributes.size());
attributes.scalarAttributes[Attribute_Pos].data = emitterAttributes.scalarAttributes[Attribute_Pos].data.clone();
attributes.scalarAttributes[velocityAttrKey.index].data = emitterAttributes.scalarAttributes[emitterVelocityAttrKey.index].data.clone();
}
else {
Size previousSize = attributes.size();
Size newSize = attributes.size() + emitterAttributes.size();
attributes.resize(newSize);
attributes.scalarAttributes[velocityAttrKey.index].incrementVersion();
for(Index i=0; i< emitterAttributes.size(); i++)
{
Index emitterIndex = i * 3;
Index particlesIndex = (i + previousSize) * 3;
attributes.scalarAttributes[Attribute_Pos].data[particlesIndex+0] = emitterAttributes.scalarAttributes[Attribute_Pos].data[emitterIndex+0];
attributes.scalarAttributes[Attribute_Pos].data[particlesIndex+1] = emitterAttributes.scalarAttributes[Attribute_Pos].data[emitterIndex+1];
attributes.scalarAttributes[Attribute_Pos].data[particlesIndex+2] = emitterAttributes.scalarAttributes[Attribute_Pos].data[emitterIndex+2];
attributes.scalarAttributes[velocityAttrKey.index].data[particlesIndex+0] = emitterAttributes.scalarAttributes[emitterVelocityAttrKey.index].data[emitterIndex];
attributes.scalarAttributes[velocityAttrKey.index].data[particlesIndex+1] = emitterAttributes.scalarAttributes[emitterVelocityAttrKey.index].data[emitterIndex+1];
attributes.scalarAttributes[velocityAttrKey.index].data[particlesIndex+2] = emitterAttributes.scalarAttributes[emitterVelocityAttrKey.index].data[emitterIndex+2];
}
}
}
function eulerIntegration_perPoints(
in Index index,
in Scalar time,
in Scalar timestep,
io ScalarAttribute positionsAttr,
io ScalarAttribute velocitiesAttr,
io ScalarAttribute ageAttr,
in Vec3 force ) {
if(time == 0) {
ageAttr.setScalarValue(index, 0, 0.0);
}
else {
Vec3 newVelocity = velocitiesAttr.getVec3Value(index * 3) + force * timestep;
Vec3 newPosition = positionsAttr.getVec3Value(index * 3);
newPosition += newVelocity * timestep;
positionsAttr.setVec3Value(index * 3, newPosition);
velocitiesAttr.setVec3Value(index * 3, newVelocity);
Scalar age = ageAttr.getScalarValue(index, 0);
age+=timestep;
ageAttr.setScalarValue(index, 0, age);
}
}
operator SimulatedParticleOp(
io GeometryAttributes attributes,
in Scalar time,
in Scalar timestep,
in GeometryAttributes emitterAttributes,
in Vec3 gravity,
in Vec3 force,
in Scalar intensity
) {
AttributeKey velocityAttrKey = attributes.getKey("velocities");
AttributeKey emitterVelocityAttrKey = emitterAttributes.getKey("velocities");
AttributeKey ageAttrKey = attributes.getKey( "age" );
if( ageAttrKey.type == AttributeType_Invalid )
ageAttrKey = attributes.addScalarAttribute( "age", 1);
// Scalar initAges[];
// initAges.resize(attributes.size());
// attributes.scalarAttributes[ageAttrKey.index].data = initAges;
addPoints( time, attributes, emitterAttributes, velocityAttrKey, emitterVelocityAttrKey);
executeParallel(
eulerIntegration_perPoints,
attributes.size,
time,
timestep,
attributes.scalarAttributes[Attribute_Pos],
attributes.scalarAttributes[velocityAttrKey.index],
attributes.scalarAttributes[ageAttrKey.index],
gravity+force.unit()*intensity
);
}
We finished the description for all the objects needed to created our particle system. Lets build a demo application to test it now !
The CreationPlatformApplication class:
Here is a drawing showing the general structure of a Creation Platform Application:
Those applications let you enable some common services very easily. For our particle simulation demo, we would like an undo-redo system, a timeline, being able to save the scene and to export the particles (using the Alembic exporter). All those feature are enable using keyword arguments in the __init__ function :
class ParticleSystemDemo(CreationPlatformApplication): def __init__(self): super(ParticleSystemDemo, self).__init__( setupGlobalTimeNode=True, setupSelection=True, cameraPosition=Vec3(19.0,13.0,-4.0), cameraTarget=Vec3(0.0,2.0,0.0), setupUndoRedo=True, timeRange=Vec2(0.0,10.0), timeAsSeconds=False, setupPersistence=True, setupExport=True )
Then we can setup the simple particle system:
# build the Particle System particles = SimulatedPoints(scene, name='Particles', time=time) emitter = DiscEmitter(scene, pointCount=200, time=time) force = Force(scene, direction=Vec3(1,0,0), intensity=0) particles.addComponent(SimulatedParticleComponent(emitter=emitter, force=force))
To be able to access easily any SceneGraphNodes, another Python object called Scene is used.
It is always required when you instantiate a SceneGraphNode. The scene let you access a node like this : camera = scene.getNode(‘Camera’)
Conclusion
As you can see, we built a Particle Simulation system with just one type of emitter and one type of force – the purpose of this tutorial was to understand key principles first – but it could be a good exercise for the reader to add some other types. Also, we did not deal with multiple emitters/forces referencing. It is not very complex but it was too much to cover in 101 course. We will see how to deal with this problem in a future tutorial.
We hope you enjoy being able to create you own particle system from scratch using Creation Platform. Comments are welcome !
If you’re signed up to our beta, you can get all of the source code for this application on github



Twitter
Facebook
LinkedIn
Vimeo
Google +