In this tutorial we go over how to recreate the Thanos portal effect from Avengers: Infinity War. This effect makes use of POP networks to drive a pyro-solver. We also use lightning from our lightning tutorial series on Patreon.
The only time we really get to see the effect in its full glory is near the end of the movie (spoiler alert ahead I guess?) when Thanos arrives in Wakanda. You can see that shot below:
Below we go over how to recreate this effect.
To start this effect, we begin by creating a circle that will drive the overall shape of this effect. We also transform it to ensure we have the portal growing from a small point.
The first part to creating this effect is to create a starter pop-net. (Keep in mind that the above shot does not show the way that the portal effect starts. That happens just before the above shot.). We create random velocities which shoot out from the curve. To do this, we enter the following code into an attribute wrangle:
float timer = @Frame/168;
v@v *= rand(@ptnum)*6*chramp('Time_mult',timer);
This code does two things. Firstly, we create a timer variable. We calculate it by dividing our current frame by our end frame. This results in a value between 0 and 1. Values between 0 and 1 are perfect for use inside of ramps. This is why, secondly, we create a ramp and multiply it by a random value per point and a value of 6 (the resultant value is a value between 0 and 6 for each point.) which we multiply our velocity by.
This gets fed into our pop solver. Our pop solver groups the faster particles by finding the length of velocity by using the following code:
ingroup = 1;
When we find the length of the velocity, the following is done:
This gives us the velocity's magnitude. It will always be positive and will tell us how powerful our overall velocity is. For example:
If our velocity is -2 on the x axis, 1 on the y axis and 0 on the z axis, then our magnitude will be (4 + 1 + 0) all square rooted. Or, 2.23.
Or, if our velocity is 5 on the x axis, 3 on the y axis and -1 on the z axis, then our magnitude will be (25 + 9 + 1) all square rooted. Or, 5.91.
As you can see, negative values do not matter. The only thing that matters is the strength of the velocity. Thus, we can figure out which particles are the fastest moving.
We group these and then replicate them. The replicated particles live long enough to get pulled back in by the pop attract.
Our second pop net uses a pop curve force to define the shape of the particles' motion. We emit many particles because when converting particles to VDB and then to smoke, we risk a grainy look due to too few particles. In the tutorial we use 1 000 000 as our constant birth rate. However, I would suggest more particles for cleaner simulations. Go for 1 500 000 upwards for final renders.
The way our pop curve force works is that it requires a curve to add forces to particles near to the curve. Below is an image that shows the three forces that are found on the pop curve force:
Due to the fact that we are working with a circle, we also want our forces to be consistent along the curve. This is why we remove all forces falloff. We also want the particles to rotate around our curve. This is why we give our orbit force the greatest value.
We also add a pop-wind because of the 'air resist' attribute that it adds. This air resistance dampens our particles' velocities. We also add a noise to our wind to add interesting flicks in our particle movement.
Both of our particle networks are saved to disk and merged together before being converted to smoke.
These particles are probably the most changed from the render file. This is because these require a lot of noise patterns to really look decent. These are quite basic in principle though; emit particles that shoot away from our curve and then pull them back with a pop-attract.
The only part that is particularly different is the creation of an attribute called @brightness. We create this attribute to later be turned into temperature. Temperature is a VDB that is recognized by Houdini. That means that it is recognized within shaders and within solvers as an attribute that causes changes (more on this later).
Converting to VDB
The last step before creating our pyro-solver is to create our VDBs. These are:
To create our density, we feed our Curve particles and Starter particles into a VDB from particles node. This node converts each particle into a voxel. A voxel can be thought of as a 3D pixel in that it is a block that contains information. Now, voxels can be different sizes but cannot be different sizes within the same VDB. This means that when choosing a resolution, it will always ultimately be decided by the resolution of your pyro-solver's division size.
In the tutorial we set our voxel size on our density to 0.015 whilst our point radius scale is 0.035. We generally want our voxel size to be less than or equal to our point radius scale to ensure enough resolution. Below we can see what happens if our voxel size is too high:
As you can see, the lower our voxel size, the closer we get to our goal shape. However, lower voxel size also means slower computation.
Once we have converted our particles to a density, we also convert our velocity to a VDB called vel. This is much like temperature in that it is recognized by Houdini.
Finally, we convert our brightness to a temperature.
That gives us three VDBs.
Note: Increase your particle number, decrease your voxel size and decrease your pyro-solver's division size to increase overall resolution.
When we create a smoke solver, we need to ensure a few things. Firstly, our scale is of importance. This is why we originally changed the circle size to 3 meters. I chose this scale because as far as I could tell, Thanos would be over 2 meters tall making his portal slightly larger. Next, we need to ensure that our division size matches our scale. If we're working in the 1-5 meter range, a division size of 0.02 - 0.04 should suffice. A good rule of thumb is generally your scale divided by 100 to get your division size (1 meter - 0.01 division size, 10 meters - 0.1 division size, 100 meters - 1.0 division size). Of course that's a rough estimate and depends on other factors. Finally, we need to ensure that our boundaries are dynamic. In other words, our bounding box for our simulation needs to auto-adjust itself. This is why we use a gas resize fluid dynamic to adjust the boundary size. We set it to equal our density VDB plus some padding. This ensures that we do not end up with a bunch of empty voxels which take extra time to compute.
The last few things that we need to take care of are the settings in the solver itself.
For one, our dissipation needs to start high and get lower as time progresses. This is because high dissipation matches the earlier parts of this effect (fast disappearing smoke) whilst lower dissipation matches the latter parts of this effect (wispy, thick, long-lasting smoke). We also need to ensure that our dissipation is not driven by temperature, thus we remove temperature as a control field.
Other than that, we only need to add a small amount of disturbance, and some sharpening for more well defined smoke. Once we have that in place, all that we need to do is cache this out to disk.
Once we have everything in place, the finishing touch is the lightning. In the original render file, I use a separate stream of particles to create lightning as opposed to deleting from the curve particles. The code that we use is as follows:
The above code is quite simple despite the complexity of the second random function.
This removes a percentage of points. You may be wondering why it is percentage based and that is because we are creating a random value for each point between 0 and 1 and deleting points that are above that threshold. 0 to 1 can be treated as 0% to 100% as the distribution is vastly uniform across the rand() function. This is why we can check if a value is greater than 0.00001 and remove a point. Keeping in mind that we have approximately one million points, we should end up with approximately 10 points if we keep 0.00001% of them.
Note: @id is unique per point and does not change over time. This is useful for keeping the same points alive despite the number of points changing.
We then remove more points by saying:
This gives us a random value per point that changes anywhere between every tenth and every fifth of a second. To understand this properly, it is useful to work backwards:
The fit() function adjusts our random value from a minimum of 0 and a maximum of 1 to a range of 0.5 and 1. This is multiplied by 10. That value is multiplied by our time and the whole value is rounded to the nearest integer using rint(). This gives us a new random value anytime between every tenth of a second and every fifth of a second. If that value is greater than 0.5, remove our point.
This causes some interesting jittering where our lightning appears and disappears intermittently.
We then trail points and add then by attribute @id to make lines. This gets fed into the lightning HDA that we go over in the lightning tutorial series. You can easily use the lines with a noise value but if you'd like to learn how to do the branching, the noise and the shape as well as how to render it or connect it to surfaces, you can check it out here
Patrons can also download Flippy The Destroyer:
That's all for this tutorial. If you need anything, drop a comment on our YouTube channel or on this blog-post and we'll get back to you as soon as possible.
Thanks for watching!