Particle tutorial advanced mesh and texture sampling

From PopcornFX
Jump to navigation Jump to search
! IMPORTANT ! This is a PopcornFX v1 page. PopcornFX v2 documentation can be found here
For PK-Fx Editor version : 1.4.0 and above
Main page: Particle tutorials

In this tutorial, we'll see:

  • How to sample different mesh properties.
  • How to use parametric coordinates to keep coherent unique sampling locations.
  • How to sample textures with the mesh UVs and use the color values to control particle properties.
  • How to control where particles are allowed to spawn on the mesh using a texture mask.
advanced mesh and texture sampling
Final result

Spawning on the mesh

After creating the new effect in the content browser, and firing-up the particle editor, the first thing we'll do is to load the mesh it is meant to spawn on as a backdrop.

This will make it easier to visualize the effect in its real-world environment:

Tuto3 01.jpg

We want to make the particles spawn on the mesh surface. In order to do this, we'll first create a new shape sampler:

Tuto3 02.jpg

Set its type to 'MESH'.
After setting the mesh path, we can check everything went allright by toggling the show shapes debug button.

The displayed shape mesh should match the backdrop perfectly:

Tuto3 05.jpg

Now, nothing has changed yet, and the particles still spawn at the origin, and form a continuous vertical rising column (due to the {0,2,0} constant acceleration in the physics evolver).

That's because we haven't told the particles to spawn on the mesh. To do this, we're going to select the 'Spawner Script' node, sample the shape's positions, and set the particle position to the sampled value:

Position = Sampler_0.samplePosition();

Note that if you renamed the sampler to, for example: 'MyCoolMesh', you'd write:

Position = MyCoolMesh.samplePosition();

We'll also shrink the particles a bit by setting the Size field to, for example, 0.005 units.
After saving the script, you should see tiny particles spawning on the mesh surface, and immediately rising up:

Tuto3 06.jpg

Now, for the next steps, we're going to significantly increase the particle count to better see what's going on in the mesh sampler.
To capture the images for this tutorial, I boosted the 'SpawnCount' field in the particle layer from 10 per second to 80 000 per second. That's a bit hardcore, but the runtime should be able to handle it easily.

Also note how the Performance budget indicators go overbudget, from green to red, and tell you there's perhaps too many particles:

Tuto3 07.jpg

To make visualisation easier, we'll just quickly clear the acceleration field in the physics evolver and set it to zero. the new particles should stop moving and stay on the mesh surface:

Tuto3 08.png

If you go back to the shape sampler, you can see there is a 'MeshSamplingMode' field, giving you two choices: either 'Fast' or 'Uniform'.

you can experiment and check what difference it makes. Basically the 'Fast' mode is.. well... faster than the uniform mode, and gives an equal spawn probability per triangle, whereas the 'Uniform' mode produces a constant spawn probability per area, but costs slightly more (both memory and performance-wise, but not that much in practise either).

What this means is:

  • 'Fast' mode : all triangles get the same number of samples, small triangles will have the same number of samples as larger ones, so areas in the mesh that are more dense will appear to have more particles.
  • 'Uniform' mode : regardless of triangle density in the mesh, there is the same number of particles per unit area, and everything is uniformly distributed across the mesh surface.

Regardless of performance considerations, both modes can be interesting. You can use fast mode and a custom tesselated mesh to make the system spawn more particles on a specific area of the mesh.

Tuto3 09.png Tuto3 10.jpg
Fast Uniform

Here is an example of the difference between 'Fast' and 'Uniform' sampling with a different mesh, designed to show the difference more explicitely:

ShapeSampling MeshDistribution Mesh.jpg
Base mesh : non-uniformly tesselated plane
ShapeSampling MeshDistribution Fast.jpg ShapeSampling MeshDistribution Uniform.jpg
Fast sampling Uniform sampling

Parametric coordinates and multi-property sampling

All we did yet was to sample a single property of the mesh: Positions.
The shape sampler actually allows to sample The following mesh properties, if they exist:

  • Positions
  • Normals
  • Tangents
  • The first texture coordinates stream (UV0)
  • The first vertex colors stream (Color0)

The following functions are published as members of the sampler to the scripts, depending on the stream's availability:

SamplerName.samplePosition();	// returns a float3
SamplerName.sampleNormal();	// returns a float3
SamplerName.sampleTangent();	// returns a float4
SamplerName.sampleTexcoord();	// returns a float2
SamplerName.sampleColor();	// returns a float4

If the mesh bound to 'SamplerName' does not contain one of the corresponding vertex streams, the function won't be declared as a member of the sampler.
So, for example, if the mesh bound to 'SamplerName' does not contain normals, calling 'sampleNormal()' will result in a script compile error.

Now, we're going to try moving the particles along the mesh surface normal, this should give the impression of a 'shell' around the mesh, made of particles.

The first thing that comes to mind is to take the position on the surface, to take the normal at that point, and to move the position along the normal:

Tuto3 shell.png

To do this, we can try writing:

Position = Sampler_0.samplePosition() + Sampler_0.sampleNormal() * 0.1;

(The 0.1 scale is here just because the ring is a small object, and normals are normalized with a length of 1 unit, so it would move the particles too far).

However, it doesn't produce the expected result:

Tuto3 11.jpg

It rather gives the impression of sampling the volume of the shell, rather than the surface of the shell.

This is because the shape sampling is random. each call to a sample() function will sample the mesh at a different location.
Therefore, in the following line:

Position = Sampler_0.samplePosition() + Sampler_0.sampleNormal() * 0.1;

The first call to samplePosition() will sample the mesh position at some random location, and the call to sampleNormal() right afterwards will sample the mesh at some OTHER random location.
This gives a result roughly equivalent to moving the position in a random position on a 0.1-radius sphere around its original location.

We need a way to tell the shape sampler to sample the two properties at the same point on the mesh.

To do this, we're going to have to use what we call parametric coordinates.

All shape samplers allow you to sample a special value, named the 'parametric coordinate', that uniquely identifies a sample on the shape. They also allow you to give that parametric coordinate back to any of their sampling functions, and will sample the desired shape property at that parametric coordinate's location.

! WARNING ! a parametric coordinate is opaque, it's totally up to the specific shape sampler used to decide what it puts inside it.
NEVER try constructing a parametric coordinate yourself and passing it to a sampling function.
It might or might not work in this version of the runtime, might or might not work in the next runtime release. Might or might not work on another platform. Might or might not be used with another shape type...
Always give sampling functions parametric coordinates generated by the same shape type ! (sphere -> sphere, mesh -> mesh, cylinder -> cylinder, etc...)

For meshes, the parametric coordinate has type 'int3', and can be retreived like so:

int3 pCoords = Sampler_0.sampleParametricCoords();

the variable 'pCoords' now contains a random sample's location in the shape. You can pass it to any of the samplePosition, sampleNormal, sampleTangent, sampleTexcoord, or sampleColor functions:

int3 pCoords = Sampler_0.sampleParametricCoords();
Position = Sampler_0.samplePosition(pCoords) + Sampler_0.sampleNormal(pCoords) * 0.1;

It is now obvious that it works as we initially intended, and we can clearly see the 'shell' around the mesh, formed by pushing all particles 0.1 units along the surface normal:

Tuto3 12.jpg

Now that the difference with/without parametric coordinates is visually clear, a more interesting thing to do with the surface normal is to initialize the particle velocity with it. this will make the particles "shoot out" of the mesh surface:

int3 pCoords = Sampler_0.sampleParametricCoords();
Position = Sampler_0.samplePosition(pCoords);
Velocity = Sampler_0.sampleNormal(pCoords) * 0.1;

Before going further, we'll just quickly set the billboarder to 'VelocitySpheroidalAlign' to stretch the particles along their velocity, and empathise their movement:

Tuto3 14.jpg

Texture sampling

We're now going to see how to sample a texture using the mesh UVs, and use one of the texture color channels to tell make the particles spawn only on certain parts of the mesh.

Before that, we're just going to quickly add a curve to fade the particle colors:

Tuto3 16.jpg

To add the texture sampler, right click on the 'Samplers' node, and select New Sampler > CParticleSamplerTexture. it will create a new texture sampler, under the already existing shape sampler.

Here is the texture we're going to use:

RGB Tuto3 tex.png
Channel decomposition:
Red Tuto3 texR.png
Green Tuto3 texG.png
Blue Tuto3 texB.png
Alpha Tuto3 texA.png

First thing, select the texture sampler, and click on its 'TextureResource' field. The texture resource browser will pop. Just pick the texture to use.

Note: if the texture doesn't appear in the list, you can try clicking 'cancel', explicitly rebuilding the resource registry by right-clicking in the content browser, selecting the 'Rebuild Registry' menu item, and re-opening the texture resource browser.

Tuto3 17.jpg

A view of the selected texture will appear in the particle node editor. You can toggle the 'R', 'G', 'B', and 'A' buttons to visualize the corresponding channels:

Tuto3 18.jpg

Back in the spawn script, to sample the texture, we first need the UVs to sample at:

float2	uv = Sampler_0.sampleTexcoord(pCoords);

This will create a temporary float2 variable named 'uv', and store inside it the mesh UVs where the particle was spawned.

we can now use this texture coordinate to ask the texture sampler to give us the texture color at that location:

float4	maskColor = Mask.sample(uv, textureAddr.Wrap, textureFilter.Point);

A few things to note here:

  • we sample 'Mask' just because that's the name I gave to the texture sampler after creating it. by default, it was named 'Sampler_1' (see screenshots)
  • textureAddr.Wrap tells the sampler we want the texture to tile/repeat if the texture coordinates go outside the [0,1] range. Available values are:
    • textureAddr.Wrap : tiles the texture
    • textureAddr.Clamp : clamps the UVs to the texture borders, and extends the border colors
  • textureFilter.Point tells the sampler we want non-interpolated pixel values. Available values are:
    • textureFilter.Point : uses the color of the nearest texel
    • textureFilter.Linear : interpolates between the 2*2 texel block surrounding the sample location.

Now, we've got the color in the spawn script, but we're not doing anything with it yet.

Something easy we can try is using one of the channels to scale the particle size. For example, to scale the size by the alpha channel, we'd write:

float2	uv = Sampler_0.sampleTexcoord(pCoords);
float4	maskColor = Mask.sample(uv, textureAddr.Wrap, textureFilter.Point);

Size = 0.005 * maskColor.w;

This effectively gives a size of zero to all particles that spawn on the mesh where the texture's alpha channel is zero, making them invisible:

Tuto3 19.jpg

However, note that they are still there ! if you look at the counters on the top-right viewport corner, you'll see there are still around 80 000 particles alive at any given time.

A much better thing to do would be to scale the Life of the particles, this way, particles spawned in the black texture areas will have a life of zero, and won't even be emitted:

Size = 0.005;
Life = 1.0 * maskColor.w;

After saving the script, you can see the particle count immediately drop to around 5 000, instead of 80 000 before !
(and the memory from ~4.6Mb to ~305Kb)

Tuto3 20.jpg

Note that in the above screenshots, there is a subtle visual difference between scaling the particle size and scaling the particle life. That's because the alpha channel we used doesn't only contain pure white and pure black, there are some light shades of gray in some places (the two large horizontal stripes), making the particles either a bit smaller or live a bit less time than in other places:

Tuto3 texA.png

Using image channels to control properties

Now that we've got our texture sampling working, we can do other fun stuff like changing the particle color depending on where it's spawned.
We'll use the red and green channels to build a reddish color coefficient: {R, G, G} (the green channel is dimmer than the red channel in some areas, so this will make the color coeff more red in those areas)

However, as we already have a color curve that sets the color each frame, we can't just write in the spawn script:

float4	maskColor = Mask.sample(uv, textureAddr.Wrap, textureFilter.Point);
Color = maskColor.xyy1;	// xyy1 will give a value equal to { red, green, green, 1.0 }

The color would be overwritten immediately during the first frame by our color curve evolver.
Instead, we'll create a new particle field named 'ColorCoeff' (there's a preset in the New particle field dialog), set its value in the spawn script, and use it at evolve-time to scale the color sampled by our color curve.

ColorCoeff = maskColor.xyy1;	// xyy1 will give a value equal to { red, green, green, 1.0 }

Also bring the particle life up a bit, to 2 seconds

Tuto3 22.jpg

Note that 'ColorCoeff' appears in the particle field list with a red background and a warning icon. This is because it is considered by the system to be an unused field.
Basically, it is currently useless. We set a value into it, but it's not used anywhere. yet.

Right-click on the evolve state, and create an evolve script:

Tuto3 23.jpg

The evolve script will be added right after the color curve, which is what we want. if we moved it above/before the color curve in the list of evolvers, the color curve would be executed after the script evolver, and would overwrite whatever values we would have set to the 'Color' field in the evolve script.

Select the newly created script evolver node, and type:

Color *= ColorCoeff;

this is equivalent to:

Color = Color * ColorCoeff;

just shorter...

Save the script, and you should see immediately the particles getting a reddish tint where the green texture channel was dimmer, namely, the horizontal stripes (that map to the spirals around the front of the ring), and the runes:

(Also note that the 'ColorCoeff' field isn't red anymore : it's not unused anymore, as we're now using it to scale the particle color)

Tuto3 24.jpg

We can now also increase a bit the particle count for a denser effect. Here, I set it to 200 000 particles per second.
The number has to be that high because we're throwing a lot of them away, as there is a large portion of the mesh where no particles should appear at all. You can see that the real final number of particles is around 26 000, not 200 000.

Anyway, that's just for the tutorial's sake, you probably won't want to use that much particles in a real game anyway.

Tuto3 26.jpg

A small detail to note when looking at the particle emission pattern is that the velocity does not follow exactly the mesh facets. That's because the normal we sample is not the face normal, but the interpolated vertex normal. There is currently no way to sample the face normal in the mesh sampler.

If you really must sample face normals, you should still be able to do so by creating a mesh with unwelded vertices, effectively having 3 unique vertices for each 'facet'-like triangle. This can be easily accomplished by using smoothing groups in your modeling software.

Tuto3 25.jpg Tuto3 27.jpg

now that we've got the hardest part of the effect done, we can add a bit of variety and prettyness by adding some upward acceleration (here, {0,0.15,0}), a bit of friction (here, 0.5) :

Tuto3 28.jpg

A turbulence sampler:
(see Particle tutorial fire for details on how to create a turbulence sampler and bind it to the physics evolver)

Tuto3 29.jpg

After some tweaking:

Tuto3 30.jpg

A final touch: make the particles spawning on the runes larger than the rest, using the Blue channel.
(The blue channel is white only on the rune's locations)

Tuto3 texB.png

Size = 0.005 + 0.01 * maskColor.z;

Tuto3 32.jpg

Here are the final scripts for the effect:

Spawn script:

function void     Eval()
	int3	pCoords = Sampler_0.sampleParametricCoords();
	Position = Sampler_0.samplePosition(pCoords);
	Velocity = Sampler_0.sampleNormal(pCoords) * 0.1;
	float2	uv = Sampler_0.sampleTexcoord(pCoords);
	float4	maskColor = Mask.sample(uv, textureAddr.Wrap, textureFilter.Point);

	Size = 0.005 + maskColor.z * 0.01;
	Life = 2.0 * maskColor.w;
	ColorCoeff = maskColor.xyy1;

Evolve script:

function void	Eval()
	Color *= ColorCoeff;

Previous tutorial : Trails
Next tutorial : Manual scene intersection