Skip to content

Pops Split Avoid like X-Particles

Someone on the cgwiki discord shared a link to an update from the Insydium C4D XP particles plugin, some cool stuff covering avoidance, splitting, grid noise, replication:

https://www.youtube.com/watch?v=Cr9uzVLjjLg

I thought 'surely this can't be too hard to emulate in Houdini?'. Well, it was harder than I expected. Insydium have done a great job making complex behavior easy to setup, Houdini Pops could learn some tricks here. None of the component parts of my emulation are massively complex, but it required some esoteric vex functions, pop replicates which are always a bit tricky, justborn groups, some quaternion tricks. If you've read through the JoyOfVex notes and Pops notes on the HoudiniDops page, this should be fine. If you haven't, you might wanna read those before continuing.

The other way to view this though is Houdini gives you enough power and access to do anything you want. Which is the glass-half-full way of saying it gives you more than enough rope to hang yourself. 😃

TLDR

Download hip: pops_xp_v04.hip

  • Pop source in impulse mode, $SF==1
  • Trails from pop replicate, use same impulse mode, ideally fed from second input and merge, inherit @id and @Cd only
  • Post sim remove leading particles, add sop in group by attribute, id
  • Use justborn groups to define initial states
  • Split from attrib
  • Order is important and annoying
  • Don't use force, copy force to v (or just use v directly) to get right angle paths
  • Vex required! Doh!
  • qrotate to turn @v at 90 degree values
  • pccone to do nearpoint checks in specific directions (nearpoints/popproximity are too broad)

Basic setup

Using the XP video as reference I emitted from a square (hot trick: make a circle sop, XZ plane, 4 sides, rotated 45 degrees), resampled the square to maybe 15 points per side, open arc mode.

A square circle.

In pops I just wanted the particles to emit from those exact grid points, and only emit one particle on the first frame. To do this I modified the particle source mode to 'all points' instead of scatter. In order to get exactly one particle generated per point, I set const activation and rate to 0 (just to remind myself thats not used at all), impulse count to 1 which means 1 particle per timestep, and impulse activation to $SF==1. This expression will be 1 when the Simulation Frame is 1, otherwise 0, so that gets the result I'm after.

Grid noise

A popforce with noise amplitude at 1, being turned into grid-like noise motion.

Particles moving in random grid patterns on the groundplane, as seen at 20 seconds in ( https://youtu.be/Cr9uzVLjjLg?t=20 )

My first instinct here was to see if I could take the default pop force/wind, and grid it up. I'd done this before, but was curious if I could do it without looking at my earlier notes. My instinct was to look at the force vector, work out which component was biggest, make it 1, and make the others zero. The max() function if given a vector, will return a vector where all the components are set to the biggest value, eg max( {0.7, 0.3, 0.2} ) would return {0.7,0.7,0.7}. Knowing that, here's a long way to convert that to a 0 or 1 value per component:

vex
vector tmp = {0,0,0};
if ( @force.x == max(@force) ) tmp.x = 1;
if ( @force.y == max(@force) ) tmp.y = 1;
if ( @force.z == max(@force) ) tmp.z = 1;

@force = tmp;
vector tmp = {0,0,0};
if ( @force.x == max(@force) ) tmp.x = 1;
if ( @force.y == max(@force) ) tmp.y = 1;
if ( @force.z == max(@force) ) tmp.z = 1;

@force = tmp;

Sort of works, but what happens if the force has negative and positive values? I realised here I don't really want the maximum value, but the one with the biggest magnitude. As such, could use abs() to get the absolute value (ie it turns negative values into positive ones), but then you need to set the result to either +1 or -1. The sign() function just returns +1 or -1 based on if a number is positive or negative, so lets rewrite the vex statement:

vex
vector tmp = {0,0,0};
if ( @force.x == max(abs(@force)) ) tmp.x = sign(@force.x);
if ( @force.y == max(abs(@force)) ) tmp.y = sign(@force.y);
if ( @force.z == max(abs(@force)) ) tmp.z = sign(@force.z);

@force = tmp;
vector tmp = {0,0,0};
if ( @force.x == max(abs(@force)) ) tmp.x = sign(@force.x);
if ( @force.y == max(abs(@force)) ) tmp.y = sign(@force.y);
if ( @force.z == max(abs(@force)) ) tmp.z = sign(@force.z);

@force = tmp;

Works, but thats an awful lot of code. Can be reduced by using ternary operator, which takes the pseudocode

if ( a == b) then foo = 1, otherwise foo = 0

and shortens it to this:

foo = a==b?1:0

So we can re-write the above a bit tidier:

vex
vector tmp = {0,0,0};
tmp.x = @force.x == max(abs(@force)) ? sign(@force.x) : 0;
tmp.y = @force.y == max(abs(@force)) ? sign(@force.y) : 0;
tmp.z = @force.z == max(abs(@force)) ? sign(@force.z) : 0;

@force = tmp;
vector tmp = {0,0,0};
tmp.x = @force.x == max(abs(@force)) ? sign(@force.x) : 0;
tmp.y = @force.y == max(abs(@force)) ? sign(@force.y) : 0;
tmp.z = @force.z == max(abs(@force)) ? sign(@force.z) : 0;

@force = tmp;

Not that tidy though. Jake Rice provided a compact solution. Understanding this bit of cool showoff code is left as an excercise for the reader. 😃

vex
@v = @force;
@v = floor(abs(v@v / max(abs(v@v)))) * sign(v@v);
@force = 0;
@v = @force;
@v = floor(abs(v@v / max(abs(v@v)))) * sign(v@v);
@force = 0;

v vs force

Wait, why did v sneak in there? And why is force being set to 0?

If you watch the old masterclass on pops ( https://vimeo.com/81611332 ) Jeff Lait explains that force is the preferred method for pushing particles around. I'm hazy on the details cos it's been a while, but the idea is that you can have many pop nodes affecting force, then the solver will combine those on top of the existing particle velocity, to generatate naturalistic motion.

Except... if you try the above trick on @force, its too natural for our requirements.

gridding up force, particles move on smooth paths, accellerate away. Can mitigate with pop drag and pop speed limit, but its not ideal.

By copying force to v, making it be rectiliniar, and setting force to 0, we're removing all that smooth accumulation of forces, and directly pushing particles how we want. Sorry Jeff Lait.

Making trails with pop replicate

Pop replicate can be used to generate trails, here's the cheat sheet of all the silly mistakes I made, so you can avoid them:

  • connect to the second input, and merge with the original stream. This means the replicate will use the incoming particles as a reference, but won't inherit the stream groups, which will come in handy later.
  • the defaults makes a sphere of particles around every input particle, thats dumb. Change it to point mode in the shape tab.
  • the default birth behaviour is the same as a regular pop source, it'll make 500 particles per second. All we want is 1 particle per step, so set const activation and const birth rate to 0, and impulse activation and impulse count to 1.
  • the default v behavior is to inherit v, again, we don't want that. In the attributes tab change initial velocity to 'set initial velocity', and set velocity and variance all to 0.

With all that done, the particles should emit trails. Hooray!

Making sure trails can be converted to lines

Awesome Houdini sim artists would convert the trails to solid lines inside dops. I am not an awesome Houdini sim artist, I'm a simple houdini guy who likes simple solutions, so I do this outside dops with regular sops tricks.

Jump up to sops, append an add sop, go to the polygons tab, by group, it'll join all the particles into lines. But those lines will be an ugly mess.

The add sop needs more hints as to how to connect points together, which we can do via the pop @id attribute. If every leading particle has a unique @id, we can make the trail particles inherit that, then tell the add sop to create prims via @id. Easy!

Go to the replicate pop again, uncheck 'add id attributes', and in the 'inherit attributes' parm, set it to be just id.

Jump up to sops again, on the add sop, polygons -> by groups, set add to 'by attributes', and set attribute name to 'id'. Resim, hey presto, lines! Well, sort of:

Fix the bad line connections

Why are we getting those ugly extra lines connecting the start to the end of each trail?

The output of this sim is both the leading particles and the trail particles. This confuses the add sop, where the ptnum ordering puts the leading particle out of order with the trails, so they get wired the wrong way.

The easiest fix is to remove those leading particles. Put down a split sop before the add sop, and in the group field choose the trail stream (called something like stream_popreplicate or here because I named my replicate node 'trails2', stream_trails2 ), now the lines look as expected.

Change direction

Make the particles detect potential collisions and turn, as seen at 48 seconds ( https://youtu.be/Cr9uzVLjjLg?t=48 )

The challenge seemed simple enough; sense a crash, avoid it. But this proved surprisingly elusive to get a final result that looked like the reference.

At first I figured there must be a crowds node I could use, as avoiding collisions is a key part of crowd behavior. I tried the pop steer avoid, but quickly found it wouldn't work here. It uses force rather than v, and doesn't give much control over the resulting movement. This means it tends to cause the exploding results as shown earlier, and you can't force paths onto grid paths. After wrestling with the options (reduce the search radius, add a pop speed limit), I got more interesting results, but not right for this project. The avoidance tends to go on curved paths and on odd angles, and you can't override. Another solution was required.

The best I could do with steer avoid and speed limit. Too curvey, doesn't match the reference.

The last time I looked at this problem I used a pop proximity node, which counts how many particles are within a radius, and can optionally store that as @numproximity. Seems like it'd work here. But what to do next?

I figured I could test in a pop wrangle if the particle had too many points nearby, and turn. The test is easy enough:

vex
if (@numproximity>10) {
  // do stuff
}
if (@numproximity>10) {
  // do stuff
}

10 seems like a good threshold. The next step is how to turn the particle 90 degrees?

Qrotate

This seemed a good job for qrotate(), which will rotate a vector via a quaternion. All these particles stay on the XZ plane, so any rotation is always going to be around the y axis, ie, the vector {0,1,0}. The current velocity is @v. If we take our velocity and rotate it 90 degrees around {0,1,0}, then that should do what we want:

vex
if (@numproximity>10) {
  @Cd = {1,0,0};
  vector axis = {0,1,0};
  float angle = radians(90);
  vector4 q = quaternion(angle, axis);

  @v = qrotate(q, @v);
}
if (@numproximity>10) {
  @Cd = {1,0,0};
  vector axis = {0,1,0};
  float angle = radians(90);
  vector4 q = quaternion(angle, axis);

  @v = qrotate(q, @v);
}

I figured colouring the particle red would make it clear that it was turned because of this code, and not the gridded force from earlier.

When I ran it, all the points turned red on the first frame and didn't move.

FAIL

Looking closer, the popproximity node has a default search radius of 1, which is way too wide; my square is only 1 unit wide, so every particle is detecting every other particle and panic turning in tiny circles.

Set the proximity radius down to 0.05, now the particles can move, they detect a collision and... turn left, immediately turn left again, and again, and get trapped by themselves. Hmm.

Zoolander particles that can only turn left

0.02 maybe? A little better, but still the particles get trapped by their own tails.

Maybe we should try playing with the number of particles tested in the if statement? Set it to 20, and trails pass through each other unless they get to a very complex crossing of paths. Set it to 2, they immediately turn and get trapped. Hmmm.

And what about the worst case of 2 lines in a head on collision? The test would need to be if there's just 1 particle nearby, turn, but we're stuck here; the particles will detect their own trails with a threshold that low, so thats no good.

The popproximity node is basically the same as the nearpoints() vex function, and they suffer the same flaw for this use case; they search in a radius, not a specific direction. For us, we need to limit the search to stuff in front of the particle.

pccone

I went back to my usual technique for vex problem solving; type 'vex functions' into google, that takes you to the main vex functions page at sidefx ( https://www.sidefx.com/docs/houdini/vex/functions/index.html ), and use ctrl-f to find keywords on that main page.

I had used intersect() in the past for raycast style problems, but was surprised to find it had no built in feature to spread the ray; its always a search in a single ray direction.

I thought maybe the sample functions might do it, and in theory I could have used a sample_direction_cone() to get a range of vectors, then loop through those with intersect(), but that didn't feel right. I saw the pccone() function, but the pointcloud related functions always confuse me, so I skipped it.

Having found nothing, I was curious what was going on inside the pop proximity node, and was pretty sure it'd be just a thin wrapper around nearpoints(). To my surprise it wasn't, it uses a pclookup function. A point cloud function. Interesting.

I went back to the pccone help page, and saw that it seemed to do what I needed; give it a start position, an aim direction, a distance, a cone angle, it will return an array of points within that cone.

In my case the start position was @P, aim direction is @v, and the distance and cone angle would be something I'd want to tune. Maybe search 0.1 units in front, within a 90 degree spread? Worth a shot. I modified the code to be this:

vex
float angle = radians(90);
float dist = 0.1;
int maxpoints = 10;
int pts[] = pccone(0,'P',@P, @v, angle, dist, maxpoints);
int numpts = len(pts);
if (numpts>3) {
 @Cd = {1,0,0};
  vector axis = {0,1,0};
  vector4 q = quaternion(angle, axis);

  @v = qrotate(q, @v);
}
float angle = radians(90);
float dist = 0.1;
int maxpoints = 10;
int pts[] = pccone(0,'P',@P, @v, angle, dist, maxpoints);
int numpts = len(pts);
if (numpts>3) {
 @Cd = {1,0,0};
  vector axis = {0,1,0};
  vector4 q = quaternion(angle, axis);

  @v = qrotate(q, @v);
}

This still had glitches, but was more promising. Lowering dist to 0.05, I started to get more interesting behavior, but I could see points would still sometimes get stuck. I also needed to make sure that the test of 2 particles playing chicken would work, meaning if it detects just 1 particle in front, it should turn, so lowered the test to numpts>1.

In this case every particle immediately turned on frame 1. Eventually I realised why; each particle was detecting itself! Easy fix though, start the cone just a little bit in front of the particle, by adding a little bit of @v to it. So rather than start at @P, make a new vector variable pos, which is @P+@v*0.01:

vex
float angle = radians(90);
float dist = 0.05;
int maxpoints = 10;
vector pos = @P+@v*0.01;
int pts[] = pccone(0,'P',pos, @v, angle, dist, maxpoints);
int numpts = len(pts);
if (numpts>1) {
 @Cd = {1,0,0};
  vector axis = {0,1,0};
  vector4 q = quaternion(angle, axis);

  @v = qrotate(q, @v);
}
float angle = radians(90);
float dist = 0.05;
int maxpoints = 10;
vector pos = @P+@v*0.01;
int pts[] = pccone(0,'P',pos, @v, angle, dist, maxpoints);
int numpts = len(pts);
if (numpts>1) {
 @Cd = {1,0,0};
  vector axis = {0,1,0};
  vector4 q = quaternion(angle, axis);

  @v = qrotate(q, @v);
}

Getting there...

Random direction change

I noticed that even with this, the particles would often get stuck in tight loops. Thats cos like Zoolander, they can only turn left. Ideally they'd turn randomly left or right.

Looking at the rotation code, if rotating 90 degrees around {0,1,0} turns left, then rotating around {0,-1,0} will turn right. We can construct the axis vector so that the y component is randomly -1 or 1. Lots of ways to do this, I figured a lazy way would be to use rand() to make a random number between 0 and 1 based on current position, fit it between -1 and 1, and use sign() again so we ignore any fractional part, we just get -1 or 1.

vex
float angle = radians(90);
float dist = 0.05;
int maxpoints = 10;
vector pos = @P+@v*0.01;
int pts[] = pccone(0,'P',pos, @v, angle, dist, maxpoints);
int numpts = len(pts);
if (numpts>1) {
 @Cd = {1,0,0};
  vector axis = set(0,0,0);
  axis.y = sign(fit(rand(@P),0,1,-1,1));
  vector4 q = quaternion(angle, axis);

  @v = qrotate(q, @v);
}
float angle = radians(90);
float dist = 0.05;
int maxpoints = 10;
vector pos = @P+@v*0.01;
int pts[] = pccone(0,'P',pos, @v, angle, dist, maxpoints);
int numpts = len(pts);
if (numpts>1) {
 @Cd = {1,0,0};
  vector axis = set(0,0,0);
  axis.y = sign(fit(rand(@P),0,1,-1,1));
  vector4 q = quaternion(angle, axis);

  @v = qrotate(q, @v);
}

Now the particles wander in a more interesting fashion to the left or right.

Random direction change v2

More interesting yes, but they didn't match the reference. Particles can change direction too quickly, end up in tight little fractal shapes. More tweaking required.

Looking at the reference again, I noticed a few things:

  • Whey they're not about to collide, the particles don't change direction that often, maybe every half a second.
  • When they have to change direction to avoid a collision, they turn into open space, while mine would frequently crash into the closest wall.

The first problem can be solved by playing with the popforce node. Turn off the avoid popwrangle for a bit, and try and dial in less frantic direction changes. I settled on some numbers, but then noticed a fundamental flaw here; some particle trails would backtrack, looking as if they'd stopped growing, but were just tunnelling back under themselves.

This is a side effect of using noise within a popforce node; its not a random steering force, just a evolving 3d noise function. I realised gridded noise wasn't right.

Random steering instead of gridded noise

Luckily we've already worked out random direction changes for the collision stuff. Remove the popforce node and force gridding wrangle, and alter the avoid code so that we change direction if maxpoints>1 OR the current @Frame can be divisible by 12:

vex
float angle = radians(90);
float dist = 0.05;
int maxpoints = 10;
vector pos = @P+@v*0.01;
int pts[] = pccone(0,'P',pos, @v, angle, dist, maxpoints);
int numpts = len(pts);
if (numpts>1 || @Frame%12==1) {
 @Cd = {1,0,0};
  vector axis = set(0,0,0);
  axis.y = sign(fit(rand(@P),0,1,-1,1));
  vector4 q = quaternion(angle, axis);

  @v = qrotate(q, @v);
}
float angle = radians(90);
float dist = 0.05;
int maxpoints = 10;
vector pos = @P+@v*0.01;
int pts[] = pccone(0,'P',pos, @v, angle, dist, maxpoints);
int numpts = len(pts);
if (numpts>1 || @Frame%12==1) {
 @Cd = {1,0,0};
  vector axis = set(0,0,0);
  axis.y = sign(fit(rand(@P),0,1,-1,1));
  vector4 q = quaternion(angle, axis);

  @v = qrotate(q, @v);
}

Run it and.. nothing? The particles just flash red every 12 frames? Ohh hang on... we rotate @v left or right every 12 frames, but whats the initial @v? It's zero! That initial 'kick' was coming from the popforce node, which we just disabled.

Define initial velocity via just born group

Man, Houdini particles are hard. While you contemplate buying C4D and X-Particles, lets define that initial velocity.

We could do it in sops with an attrib randomise (and in hindsight thats the easier option), but for fun, lets see how to do all this in pops.

The pop source node lets you group particles that were just created, and they get removed from that group on the next frame. Jump to the birth tab, and enter a name, say 'bornA' into the Just Born Group parameter. Sure, we only make particles on the first frame of the sim, but this is a handy trick to know in other situations.

Create another pop wrangle, set its group to bornA, and we'll set that initial velocity. We'll do something simiar to the left/right turning code. We give each particle a velocity along z, say {0,0,0.1}, then rotate that randomly around the Y-axis by 0, 90, 180, or 270 degrees.

A more code-friendly way to define that is 90*random_number_between_0_and_3.

A even more vexy way to define that is 90 * rand(@id)*4 .

Rand() returns a random float between 0 and 1, we multiply that by 4 so its between 0.001 and 3.999, then round it via int() so we just get 0 1 2 or 3:

vex
vector axis = {0,1,0};
float angle = radians(90);
angle *= int(rand(@id)*4);
vector4 q = quaternion(angle, axis);
@v = {0,0,0.1};
@v = qrotate(q, @v);
vector axis = {0,1,0};
float angle = radians(90);
angle *= int(rand(@id)*4);
vector4 q = quaternion(angle, axis);
@v = {0,0,0.1};
@v = qrotate(q, @v);

After seeing this in action they were changing direction too frequently, and I also wanted the particles to travel in straight lines for a bit at the start of the sim before turning, so I changed the line in the turn code from

if (numpts>1 || @Frame%12==1) {

to

if (numpts>1 || @Frame%24==23) {

Ie, particles change direction every 24 frames, but due to the way modulo maths works, will be offset to frame 23 before turning.

Pick a better turn direction

These particles still make dumb choices and frequently crash into walls, or head into crowded sections.

To fix this, I have the particles check to their left and right, and choose the least crowded side.

This will involve 2 more pccone tests, where the direction is altered in each test by rotating @v 90 degrees left and right using qrotate. We can then compare the results, and turn in the direction that has the least number of points.

vex
vector pos, frontdir, leftdir, rightdir;
float angle, dist, dir, seed, threshold;
int maxpts;
int pts[], ptsleft[], ptsright[];

pos = @P+normalize(@v)*.00001;
angle = $PI/3;
dist = 0.027;
maxpts = 20;
threshold = 0.94;

frontdir = normalize(@v);
leftdir = qrotate(quaternion(radians(90), {0,1,0}),frontdir);
rightdir = qrotate(quaternion(radians(-90), {0,1,0}),frontdir);

pts = pccone(0, 'P', pos, frontdir, angle, dist, maxpts);
@a = len(pts);
seed = rand(@P);

if (len(pts)>1 || seed>threshold) {
    ptsleft = pccone(0, 'P', pos, leftdir, angle, dist, maxpts);
    ptsright = pccone(0, 'P', pos, rightdir, angle, dist, maxpts);
    if (len(ptsleft) > len(ptsright)) {
        dir = -1 ;
    } else {
        dir = 1;
    }
    vector4 q = quaternion($PI/2,set(0,dir,0));
    @v = qrotate(q, @v);
}
vector pos, frontdir, leftdir, rightdir;
float angle, dist, dir, seed, threshold;
int maxpts;
int pts[], ptsleft[], ptsright[];

pos = @P+normalize(@v)*.00001;
angle = $PI/3;
dist = 0.027;
maxpts = 20;
threshold = 0.94;

frontdir = normalize(@v);
leftdir = qrotate(quaternion(radians(90), {0,1,0}),frontdir);
rightdir = qrotate(quaternion(radians(-90), {0,1,0}),frontdir);

pts = pccone(0, 'P', pos, frontdir, angle, dist, maxpts);
@a = len(pts);
seed = rand(@P);

if (len(pts)>1 || seed>threshold) {
    ptsleft = pccone(0, 'P', pos, leftdir, angle, dist, maxpts);
    ptsright = pccone(0, 'P', pos, rightdir, angle, dist, maxpts);
    if (len(ptsleft) > len(ptsright)) {
        dir = -1 ;
    } else {
        dir = 1;
    }
    vector4 q = quaternion($PI/2,set(0,dir,0));
    @v = qrotate(q, @v);
}

I've now split out the important variables to be declared up front, so its easy to tweak. This is almost right, except...

Controlling particle count

Find a trail that looks like it stops early, and turn on point numbers. You'll find its not really stationary, but tunelling under other points, being replicated every frame, making needless extra points.

Too many points!

I realised I'm not doing anything here to detect when particles are trapped, and should die. Lucky thats easily fixed; if we set @dead=1, that particle will be killed by the solver. What we could do is detect if there's just too many particles nearby, and die. It's a small but important tweak, as it also affects how other particles avoid each other (without killing trapped particles, it throws off the comparison of how many particles appear to be in certain areas):

vex
vector pos, frontdir, leftdir, rightdir;
float angle, dist, dir, seed, threshold;
int maxpts;
int pts[], ptsleft[], ptsright[];

pos = @P+normalize(@v)*.00001;
angle = $PI/3;
dist = 0.027;
maxpts = 20;
threshold = 0.94;

frontdir = normalize(@v);
leftdir = qrotate(quaternion(radians(90), {0,1,0}),frontdir);
rightdir = qrotate(quaternion(radians(-90), {0,1,0}),frontdir);

pts = pccone(0, 'P', pos, frontdir, angle, dist, maxpts);
@a = len(pts);
seed = rand(@P);

if (len(pts)>1 || seed>threshold) {
    ptsleft = pccone(0, 'P', pos, leftdir, angle, dist, maxpts);
    ptsright = pccone(0, 'P', pos, rightdir, angle, dist, maxpts);
    if (len(pts)>1 && len(ptsleft)>1 && len(ptsright)>1) {
        @dead=1;
    }
    if (len(ptsleft) > len(ptsright)) {
        dir = -1 ;
    } else {
        dir = 1;
    }
    vector4 q = quaternion($PI/2,set(0,dir,0));
    @v = qrotate(q, @v);
}
vector pos, frontdir, leftdir, rightdir;
float angle, dist, dir, seed, threshold;
int maxpts;
int pts[], ptsleft[], ptsright[];

pos = @P+normalize(@v)*.00001;
angle = $PI/3;
dist = 0.027;
maxpts = 20;
threshold = 0.94;

frontdir = normalize(@v);
leftdir = qrotate(quaternion(radians(90), {0,1,0}),frontdir);
rightdir = qrotate(quaternion(radians(-90), {0,1,0}),frontdir);

pts = pccone(0, 'P', pos, frontdir, angle, dist, maxpts);
@a = len(pts);
seed = rand(@P);

if (len(pts)>1 || seed>threshold) {
    ptsleft = pccone(0, 'P', pos, leftdir, angle, dist, maxpts);
    ptsright = pccone(0, 'P', pos, rightdir, angle, dist, maxpts);
    if (len(pts)>1 && len(ptsleft)>1 && len(ptsright)>1) {
        @dead=1;
    }
    if (len(ptsleft) > len(ptsright)) {
        dir = -1 ;
    } else {
        dir = 1;
    }
    vector4 q = quaternion($PI/2,set(0,dir,0));
    @v = qrotate(q, @v);
}

'You don't need code for Houdini' they said. 'It's really easy' they said.

Splits

As seen at 1:16 : https://youtu.be/Cr9uzVLjjLg?t=76

In the video they add a trigger action to control spawning. In pops, we'll use another pop replicate, but this time rather than being used for trails, it'll generate new leading particles.

As we've seen, the replicate pop is very similar to the source pop, and shares most of the parameters to control the number of particles generated per timestep or per second. An important difference is that you can also drive particle generation from an attribute of the source particles. Here we'll create a @split attribute that will trigger new particles to be replicated.

Create a new replicate pop, wire the pop source to its second input, and merge. Go through the motions of making it behave as we want, so shape is point, birth is impulse based, this time inherit only v, and create its own id.

The more important change is to tell it what attribute will drive it. On the birth tab enable 'emit from attribute', and fill in the attribute name, I used split.

Now we have to generate that attribute. Insert a wrangle before the replicate, and we'll set split to be 1 every 20 frames, otherwise 0:

vex
i@split = @Frame%20==0?1:0;
i@split = @Frame%20==0?1:0;

If you run this sim, you'll find the splits can be a bit hard to spot, and often move the same way as the particle they just split from. We'll use another justBorn group here to make sure the split particles immediately move in a different direction.

Ensure splits move in a new direction

On the replicate put a name into the just born group parameter, I used bornB (so they can be identified seperately from the bornA group we used earlier).

Append a wrangle after the replicate, and use similar logic to what we did before to force them onto a new path. Make sure to set the group parameter at the top to 'bornB' so this logic is only executed for 1 frame, as soon as the new particles are born.

vex
vector pos, frontdir, leftdir, rightdir;
float angle, dist, dir, seed, threshold;
int maxpts;
int pts[], ptsleft[], ptsright[];

pos = @P+normalize(@v)*.00001;
angle = $PI/3;
dist = 0.027;
maxpts = 20;
threshold = .1;
@Cd = chv('col');
frontdir = normalize(@v);
leftdir = qrotate(quaternion(radians(90), {0,1,0}),frontdir);
rightdir = qrotate(quaternion(radians(-90), {0,1,0}),frontdir);

pts = pccone(0, 'P', pos, frontdir, angle, dist, maxpts);
@a = len(pts);
seed = rand(@P);

if (len(pts)>1 || seed>threshold) {
    ptsleft = pccone(0, 'P', pos, leftdir, angle, dist, maxpts);
    ptsright = pccone(0, 'P', pos, rightdir, angle, dist, maxpts);
    if (len(pts)>1 && len(ptsleft)>1 && len(ptsright)>1) {
//        @Cd = {1,0,0};
        @dead=1;
    }
    if (len(ptsleft) > len(ptsright)) {
        dir = -1 ;
//        @Cd = {0,1,0};
    } else {
        dir = 1;
//        @Cd = {0,0,1};
    }
    vector4 q = quaternion($PI/2,set(0,dir,0));
    @v = qrotate(q, @v);
} else {
// @Cd = {1,1,1};
}
vector pos, frontdir, leftdir, rightdir;
float angle, dist, dir, seed, threshold;
int maxpts;
int pts[], ptsleft[], ptsright[];

pos = @P+normalize(@v)*.00001;
angle = $PI/3;
dist = 0.027;
maxpts = 20;
threshold = .1;
@Cd = chv('col');
frontdir = normalize(@v);
leftdir = qrotate(quaternion(radians(90), {0,1,0}),frontdir);
rightdir = qrotate(quaternion(radians(-90), {0,1,0}),frontdir);

pts = pccone(0, 'P', pos, frontdir, angle, dist, maxpts);
@a = len(pts);
seed = rand(@P);

if (len(pts)>1 || seed>threshold) {
    ptsleft = pccone(0, 'P', pos, leftdir, angle, dist, maxpts);
    ptsright = pccone(0, 'P', pos, rightdir, angle, dist, maxpts);
    if (len(pts)>1 && len(ptsleft)>1 && len(ptsright)>1) {
//        @Cd = {1,0,0};
        @dead=1;
    }
    if (len(ptsleft) > len(ptsright)) {
        dir = -1 ;
//        @Cd = {0,1,0};
    } else {
        dir = 1;
//        @Cd = {0,0,1};
    }
    vector4 q = quaternion($PI/2,set(0,dir,0));
    @v = qrotate(q, @v);
} else {
// @Cd = {1,1,1};
}

There's some extra stuff in there, commented lines so I could debug the turning logic, and I set the colour via a parameter (pink to match the video ref).

Check group names, stream names, id

Ensure that the trail sop picks up the name of this new replicate group, that the split particles definitely get their own unique id, and that the add sop and split sops know which groups/streams to operate on. It becomes a bit of a data wrangling exercise, but not too bad.

Multiple branches on smooth paths

As seen at 1:58: https://youtu.be/Cr9uzVLjjLg?t=109

Particles moving on smooth random paths, and every 20 frames or so a burst of particles is emitted, which all inherit some velocity from the parent particle, all leaving trails.

Based on whats covered so far, should be easy(ish) to reconstruct.

First, the leading particles moving on smooth random paths. While the crowd steer nodes didn't work for the 90 degree turns, they should be perfect here. A popsteerwander works well, but I found would accellerate particles over time. Throwing in a pop speed limit calmed this effect down.

Multi splits

No special treatment is required to make one replicated particle vs many. A split attribute drives when they split as before, the birth settings are much the same (impulse based, shape is set to point), the only difference is the impulse count on the birth tab isn't 1, but whatever number of particles you need. I set mine to 10.

I didn't play with this as much as I could to match the video, but did some basic tweaking. I use the just born group again to tweak the replicated particle initial settings, so they get slowed to 70% of their parent velocity, and then by NOT using the just born group, I set their v to

@v *= 0.993;

which will slow them down a small amount every frame.

Also, the replicate pop is set to have a shorter lifespan, so the splits leave trails for about half a second, then die.

Fern or feather shapes

As seen at 2:21 : https://youtu.be/Cr9uzVLjjLg?t=141

Was pleased that my intuition here was correct, and got the behaviour I wanted first try.

This is just a bunch of particles emitting from a single location, so start with a poplocation node, birth maybe 10 particles in impulse mode, on only the first frame. Setting the attribute velocity variance high on X and Z will make them burst in all directions.

The replicate/split behavior is the same as the previous setup, but this time emitting every 2 or 3 frames.

The split velocity needed more control, but again building on concepts already covered. We want the splits to turn left or right from the input @v, but rather than turning 90 degrees, turn a much smaller angle, say 20 degrees. Here's the vex I used on a justborn group for those replicated particles:

vex
@Cd = chv('col');
@v *= 0.7;
float turn = @id%2==1?1:-1;
vector axis = set(0,turn,0);
vector4 q = quaternion($PI/6,axis);
@v = qrotate(q, @v);
@Cd = chv('col');
@v *= 0.7;
float turn = @id%2==1?1:-1;
vector axis = set(0,turn,0);
vector4 q = quaternion($PI/6,axis);
@v = qrotate(q, @v);

Turn is used as the y-axis for the rotate, 1 will turn left, -1 will turn right. Because the split will generate their own id, and I always generate 2 particles, I just get their @id, and say if it can be divided by 2 turn left, otherwise right.

The angle is $PI/6 radians, which is... some amount of degress? I dunno, I just knew that $PI/2 is 90 degrees, $PI/4 is 45 degrees, so kept using higher numbers until I found one that felt right.

Adding collisions

As seen at 2:56 : https://youtu.be/Cr9uzVLjjLg?t=176

Lucky this isn't hard with pops; just add collisions as you normally would with a static object, on the pop solver go to the collisions tab, and set response to 'die'.

Final notes

I mentioned in the TLDR section that order is important and annoying. By that I mean these setups can behave strangely, or stop working all together if you put wrangles before or after replicates. My understanding is that it shouldn't matter, results from this frame just get fed into the next frame, so at worst results will happen with a 1 frame delay, but replicates seem to always ruin my vibes.

When things started misbehaving I'd often have to disconnect all the pop nodes, make sure the simplest case worked, then re-insert a single wrangle, check, add the next, check, until I discovered the misbehaving order. The setup in the example hip I think is correct now, but something to watch for.

Also watch those stream wirings and stream names. Get it wrong and you can find replicates feeding on themselves, point counts explode. It all feels more fraught than it needs to be, but I guess its all on par with usual Houdini Dops vibes; this is a surgical tool, Sidefx expect that you're a grown up and can handle it. 😃

If you found this article useful, supporting me via Paypal or Patreon would be nice: Support