0 like 0 dislike
3.6k views
I want to parent a sphere of "dust" around the user that he/she can move through.
I'd like the particles to have either an infinite life, or very high to retain immersion. In order to keep things efficient I need to be able to kill all particles that move out of range, while spawning new ones in to fill the quota.

I'm not familiar with how to use the kill function, especially since the scripting doesn't support traditional if statements. As well, I don't know how to make a particle effect spawn particles to meet a quota, and no more.

Any help is appreciated, thanks!
by Janooba (180 points)

2 Answers

1 like 0 dislike
 
Best answer

Hi,

This is an interesting problem.

Good news is: it can be solved, no problem ! :)
Bad news is: it's not _that_ easy, so this answer might get a bit long. I'll try to keep things simple yet detailed enough.

Edit: Damn, this got insanely long :( sorry about that. Hopefully you'll grasp all the concepts needed to make this work after the read :)

If you want to start playing with this right away (it will also help follow the steps below), I've uploaded a package with some effects illustrating what I'm talking about at the end of this post.

 

First thing, let's get the question about "kill" out of the way here:

How to use "kill":

It expects the same value as the 3rd argument to "select" or the 1st argument to "iif", that is, a true/false boolean value.

However, scripts don't support the "bool" type (they have "true" and "false" though), so it's actually an "int" type.
Any compare statement will return those true/false values:

kill(Position.y < 0); // will kill the particle as soon as its worldspace 'y' coordinate goes below 0

kill(!MyShapeSampler.contains(Position)); // particle will die if it's not contained in the shape

etc...

Note that if you're comparing vectors, the result will actually be a vector of results.
Because kill() only expects a single true/false value, this won't work:

kill(PositionA != PositionB);

float3 != float3 returns an int3 result, that will contain, for example, int3(false, true, false), if only the 'y' coordinates of PositionA and PositionB are different.

you'll need to reduce this to a single bool value, either by hand like this:

int3 test = PositionA != PositionB;
kill(text.x | test.y | test.z); 
// kill if at least one axis is different

or by using the "all" or "any" helpers:

kill(any(PositionA != PositionB)); // kill if at least one axis is different
kill(all(PositionA != PositionB)); // kill only if all axes are different

 

Basic effect setup

The main idea is to parent the whole effect to whatever it needs to move along with (for instance, the camera).

We'll then be able to use a localspace evolver to test if particles are inside a shape that defines that range.
To test this movement inside the popcorn editor, you can select the "Spawner" folder at the root of the particle treeview:

It'll show a gizmo in the viewport, that you can move around to move the effect instance just like it would ingame when following an ingame object:

That's a simple setup with particles randomly spawning on a "cylinder" shape with height=0, in "volume" mode. This basically samples a disk, It's what I'll use for the rest of the examples below.

 

With that out of the way, let's see how to solve the real problem, which can be split in two main parts:

  1. killing particles that leave the area they're supposed to be in
  2. deciding when and how to spawn particles so that they fill the newly "discovered" area.

 

1- Killing particles that are out of range:

To do this, first create an evolve script that just does this:

function void    Eval()
{
    kill(!MyAreaShapeVolume.contains(Position));
}

The 'contains' function of shapes takes a position and returns true or false if this position is inside the shape.
(See the scripting reference or shape sampler reference for more details)

The boolean operator '!' (read "not") inverts that result, then we just pass that this to the kill function.
So you can read this as "kill when the shape does NOT contain the particle position"

Here's the result:

Obviously there's something very wrong.
The 'Position' field of the particles are in worldspace.
The shape is centered at {0,0,0} (or whatever other location you've set inside its properties), and this is also treated as a worldspace location.

Thing is, the way popcorn works, we can't say in the shape sampler itself "dynamically move at this location in the world, or follow the emitter". Samplers are statically defined resources, except attribute samplers, but their per-instance values can't be accessed inside evolvers.

So, the standard way to make the shape "move" along with the effect instance is to use a localspace evolver.
The localspace evolver will temporarily "move" all worldspace fields (including Position) to the emitter's local-space, then run all child evolvers, then move back all fields to worldspace.
This will cause all evolvers inside the localspace evolver to "see" those fields in the local coordinates of the instance.

Make sure you've set the localspace evolver modes to:

ModeEnter : WorldToLocal_Current
ModeLeave : LocalToWorld_Current

otherwise, with its default values, it will move all particles along when you move the emitter.

Using a script that does "shape.contains(Position)" inside the localspace evolver will now lead the expected result:

Now that we can properly kill the particles that leave the area around the camera, let's see how we can setup this with infinite particles and how we can spawn new particles as it moves around.

You'll find this effect in the .pkkg linked at the end of the post, it's the one named "AreaSpawn_KillOutOfRange‚Äč.pkfx"
 

2- Spawning new particles in the new areas

You have multiple possible approaches here:

Option A : Random spawn and target quota.

keep randomly spawning new particles in the area til a quota is met.

This is the simplest one, but will give shitty results.
You won't have control over where new particles are spawned, some will appear next to the camera, and the density distribution won't be uniform. Because of the killing taking place behind the camera as it moves, but yet a uniform spawn distribution, you'll have more particles behind you and less in front of you as you move.

Still, here's how you'd do this, just FYI, as it might be useful to you in other effects:

First, select the layer and uncheck "ContinuousSpawner", you usually want this active, but not here, it'll try to interpolate spawn positions between the previous and current frame, whereas you want to spawn all new particles exactly at the location the effect has in the current frame.

Then, the main work boils down to a single layer script:

function void    Run()
{
    float    missingCount = Budget - LiveCount;
    Flux = select(0, missingCount / dt, missingCount > 0);
}

This uses the "LiveCount" property, available only in layer scripts, that contains the current number of live particles for this layer instance. 'Budget' is just an attribute containing the target particle count.

Here, we're setting 'Flux' to either 0, if we're above or equal to the budget, or to the number of missing particles divided by the frame-time ('dt'). This trick will force the layer to spawn that exact count this frame.

The layer treats the 'Flux' as a multiplier to the number of particles per second it has to emit, therefore, each frame, to compute the number of particles it needs to spawn, it does:

numberToSpawn = Flux * numberPerSecond * dt;

but we know the exact count we want to spawn this frame. not per second, this count is the difference between the current 'LiveCount', and our budget.

therefore, to get the layer to spawn that exact count, we set its number of particles per second to 1.0 in its properties, and its Flux to number/dt in the layer-script, this will give:

numberToSpawn = (countWeWant / dt) * 1.0 * dt;

the mul and div by 'dt' cancel each other out, and we're left with:

numberToSpawn = countWeWant;

In addition with the killing when it leaves the shape, here is the result:

(Here I colored the newly spawned particles red, then faded them to white, to better visualize what's happening)

It works, but you can see the new particles popping randomly inside the area, and clumping together opposite to the direction of motion.

You'll find this effect in the .pkkg linked at the end of the post, it's the one named "AreaSpawn_A_LiveCountBad.pkfx"
 

Option B : Respawn everything, discard the ones already here.

As soon as we move, respawn the whole quota, and kill the ones that were in the area location _before_ we moved.

There are good and bad sides to this approach:
Good sides:

  • Works with any shape
  • Works with a spawn shape that's different from the containment/kill shape
  • Still pretty simple

Bad sides:

  • As soon as you move, the effect will respawn ALL its budget particles, then kill all the ones it doesn't need, to keep only the "new" ones. If your budget is in the tens of thousands, it'll be hard on performance.

Before diving in how to do it, here's a gif showing the result:

Here you can clearly see the new particles are properly created at the front of the motion path, everything appears solid, no popping inside the area, and it appears like the circle is just a "view" into a much larger field of particles.

Here are the rough high-level steps we'll need:

  1. detect if we moved since last frame. if we moved, it means we might need to spawn some new particles
  2. if we moved, and we have a budget of 500 particles, spawn 500 new particles
  3. kill all the ones that were inside the area of the previous frame (because we'll already have that zone populated with the older particles.
  4. we'll be left with the particles in the new zone but not in the previous zone, which happens to be the ones we really needed to spawn.

We could do #1 and #2 with a layer script.
However, #3 has to happen in the particle spawn-script, but we need access to the effect's position in the previous frame. we can access this in the layer script, but NOT in the particle spawn scripts.

To work around this issue, we'll have to use a somewhat dirty trick: we're going to have the layer create a single infinite "proxy" particle, that will have a localspace evolver to move that particle along whenever the effect moves. We'll then use a trail evolver with a custom metric to spawn the actual particles and use the custom metric to do what we'd have done in the layer script with 'Flux'.

If we make the parent particle store its previous position in a field, the child particles of the trail will then be able to use parent fields to access the parent particle's prev position, and will be able to perform step #3.

So we have the following:

  • A proxy layer with a duration of 0 that spawns a single particle.
     
  • Add a new 'float3' field named "LastPosition", with no transform flags, otherwise it'll be moved along by the localspace evolver
     
  • A first script evolver, that runs at the start of the frame, to save the previous position into 'LastPosition' :
    function void    Eval()
    {
        // 'LastPosition' has no transform flags,
        // therefore won't be moved by the localspace evolver.
        // (see the 'Fields' panel to see the transform flags)

        LastPosition = Position;
    }

     
  • A simple localspace evolver with default values that moves it around whenever the emitter moves
     
  • Then a script evolver that sets a 'float' field I've named "ChildFlux" :
    function void    Eval()
    {
        // Spawn only if we've moved this frame
        ChildFlux = select(0, Budget, any(Position != LastPosition));
    }

     
  • Then an evolver spawner with the following properties:
    - SpawnMetric : Custom
    - SpawnInterval : 1.0 <-- tells it to spawn exactly what's in the custom spawn metric field
    - CustomSpawnMetricField : ChildFlux
     
  • The following spawn-script in the child particles:
    function void    Eval() // runs in localspace
    {
        Size = 0.05;
    }
    function void    PostEval() // runs in worldspace
    {
        float3    prevPos = parent.LastPosition;
        float3    curPos = parent.Position;
        Position = StartPosition.samplePosition() + curPos;
        float3    testPos = (Position - prevPos);
        int        shouldKeep = !LifeSphere.contains(testPos);
        Life = select(0, infinity, shouldKeep);
    }

And you should be set.

You'll find this effect in the .pkkg linked at the end of the post, it's the one named "AreaSpawn_B_Discard.pkfx"
 

Option C : Only spawn what's needed, where it's needed.

Track the amount of camera movement each frame, compute overlap amount compared to previous frame, and use that to know how many particles to spawn. Then uniformly sample the "croissant"-shaped shape resulting from boolean substraction of the spawn area before the move from the spawn area after the move (assuming you're spawning on a circle).

This last one addresses the potential perf issues of option B above, it'd be useful if you had a LOT of particles to spawn in your spawn-area (tens of thousand or more), that would make respawning them all each frame prohibitive.

But it's MUCH harder to get right.
The flux computation is relatively simple, it just has to compute the circle/circle overlap area between the previous and current frame, the flux is the ratio of the non-overlapping area over the full circle area.

But the hardest part comes from generating random positions on the croissant-shaped area defined by the boolean substraction between the two circles. and I haven't taken the time to work that out right now (although I believe it should be possible), this post is already getting way too long :)

Unlike option B, you'll have to change those formulas if you need shapes other than circles, whereas option B works for any shape.

Edit: I updated the .pkkg with a rough test for this, the croissant-spawning is not perfectly accurate, it's a quickly tweaked formula with a bunch of probability curves, used to remap a uniform 2D lattice of random points to the appropriate croissant shape, while trying to keep a final uniform distribution.

Here's what it looks like, with random colors assigned to each frame particles have been spawned, to better visualize the "croissant" spawning pattern :

And with the kill:

Example package:

Here's the link to the package with the example effects:

EDIT: re-uploaded, with effect C added, semi-hacked croissant-spawn.

http://www.popcornfx.com/downloads/AreaSpawn.pkkg (PK-Editor v1.9.1)

Compared to what I've described above, I also added a bunch of attributes to these effects:
"SpawnRadius" that allows to quickly scale the radius of the spawning area
"Die" that allows you to kill everything instantly, as we have infinite particles everywhere.

For more graceful fading in and out of those infinite particles, to get a usable effect with no popping, you can also take a look at this post here : http://answers.popcornfx.com/899/unity-ondestroy-and-setting-an-attribute

by Julien (35.2k points)
Great info again. I've been tinkering with similar stuff but instead I made the particles infinite and just re-position them in evolver if they fall outside a sphere. Now I started to wonder if it's inefficient compared to killing and re-spawning.

One way to do the "croissant" could be:

moveVector = movement vector of container shape

croissantPos = newPos + (moveVector * min(0, dot(-moveVector, newPos)));

Didn't test it though. And it's not actually the croissant, but close enough if you use sphere or cylinder with some inner radius.
Wrapping is a great idea, and definitely simpler than mine :)

You could also use the "containment evolver" inside a localspace evolver, in "Wrap" mode, but it'll produce artefacts because it doesn't wrap cleanly, and after a bit of movement the particles will end up clumped together.
Maybe that's something we could fix for an upcoming release, having the containment evolver make clean wraps would allow to use it for those kind of effects.

Anyway, you can always do it manually and it should make no difference.
Doing it at evolve means it then has to be run each frame on all particles, no matter if the container moves or not.
but if the math is cheap enough, it probably doesn't matter.

The other difference with respawning is that you'll also need additional "fade-in" logic if you want no popping.
how did you solve this in your current solution? do you just reset some fields when you detect they're wrapping ?
By the way, I just re-uploaded the .pkkg, fixed a silly bug in a script of effect #B, and added effect #C with a croissant-spawning test

the performance of #C is quite nice, no evolve overhead, and unless the area moves wildly from frame to frame, the performance is better with high number of particles.
I'm doing some fading based on squared distance to camera and particles that need to be re-positioned are spawned outside of this distance. I am experiencing some clumping in certain situations and need to rework it a bit.

Going offtopic here:

I am using lot of your 'dirty' trick of using spawner evolver, though not with one under discussion. It allows much finer control over spawning in some situations, even though can get a bit complex. One thing I think would be quite beneficial is not just effect wide variables (attributes that particles could write) but also variables that are shared by layer or spawner evolver's particles. If I need some info down to spawner evolver I have to add it as field and do Field = parent.Field, even though the value would be same for all the particles spawned by that parent. And if we could write this shared variable somewhere that is run only once per frame, even better.
Ah right, you don't care about them being immediately visible and fading in with time. sure that sounds fine for anything LOD-related.

I don't know how you're wrapping, but after some tests, the clumping is inherent to wrapping on a circle/sphere.
It looks possible to get a stable wrapping on a circle/sphere by mirroring the particles that are outside along the intersection plane, but that again requires knowledge of the previous/current positions, and it'd be pretty heavy to grab (again through a localspace).
if you fade the particles per distance anyway, you can keep them on a simple square grid and wrap them by doing the following:

    float    r = SpawnRadius;
    float3    p01 = (Position / r) * 0.5 + 0.5;
    float3    pWr = p01 - floor(p01);
    Position = (pWr * 2 - 1) * r;

And this should produce no clumping at all.

Regarding writing to shared properties, something like this might become doable in the future (we're in the process of refactoring many things currently, amongst which spawner-related things).

Currently to share those values I don't see any other way than having some particles insert values in a spatial layer, then have other particles query that spatial layer at specific locations to grab these values.
We've used this to store things like scores and have advanced logic in some effects, that have "gameplay", where there's a minigame made entirely of particles. The only way to have data shared and transferred is by using spatial layers.
one thing to be aware of with spatial layers is that the values you get are from the previous frame.
Can you show the bit where you do wrapping on sphere by mirroring about intersection plane. I get the idea but somehow something is funky. Doesn't help that I am actually having the sphere where camera is (ViewPosition), a bit hard to debug.

In my case there is also some out of focus faking, so I think it could be more beneficial in my case to have a little tighter fit with less particles. Also some calculations could be moved over to host side and fed through an attribute.
1 like 0 dislike

Follow-up from the first answer + comments:

I did some timings between three techniques, on grids of 15K particles:

#1 : Option B from first answer : respawn everything and discard unneeded whenever we move.


~0.13 ms, as soon as we move, spikes to 1.05/1.1 ms
(note that the part that causes the spike only uses a single worker thread, and if other effects were running, they could run "for free" in parallel on other cores, so you'd still see 1.1 ms with more stuff going on, but that's the case for #2 below as well)

 

#2 : Option C from first answer : only spawn what's needed on the boolean substraction of the spawn areas, hereby named "croissant-spawning" :)


~0.13 ms, as soon as we move, spikes between 0.14 to 0.8 ms in extreme cases.
Nice thing here is that the most common scenario, if you picture the circle as the "view distance" of fog particles around the camera, is that the thing will only move a very tiny bit each frame, making it almost always non-spiky.

 

#3 : Option D : as discussed with Pexoid in the first answer's comments : do a simple wrapping (here, square-shaped)


~0.18 ms, does not spike at all when moving.
Also, this runs on ~19k particles, not 15K. There's a layer script scaling the flux by (4/pi) to keep the number of particles inside the circumscribed circle to the target budget of 15K.

 

Edit: I made two last gifs to help visualize the croissant-spawning :

 
          Raw random distribution | Lattice lines to view the warping
 

If you want to play with it, I added this test effect to the original package, available here: http://www.popcornfx.com/downloads/AreaSpawn.pkkg (PK-Editor v1.9.1)

by Julien (35.2k points)
This is awesome! Personally, I think Option D suits my specific situation the best, and I've already got a version of it working. The math used in option C is a little bit over my head, and I'm struggling to figure out how to make it work in a sphere.
As well, some of the options fall apart if the particles have any sort of movement applied as well.

Thanks for the help though, you guys are awesome!
Great :)

moving the particle's Position around shouldn't be a problem really, but where that motion is applied might be sensitive. The effects in the package all have a Physics evolver in them, the place it's in shows where it's safe to add evolvers that change particle position.

However, as a kill-volume is used, as soon as a particle exits that volume, it'll die and not be replaced.

but that's a problem inherent to using a kill-volume. What you can do is use two positions: A static one used for spawning/killing, and a dynamic one that's offsetted from the static one, and that's used for rendering or to spawn child particles.

One thing I often find useful is to use a turbulence to offset the position. This ensures that the particle won't fly away wildly and always "wobble" around its anchor position, but still give the turbulence look, something like:

DrawPosition = Position + Turb.sample(Position);

anyway, using pexoid's wrapping option you don't need to worry about this.
...