So, following up on the first answer and comments, you'll find a .pkkg archive to import in the editor, containing a bunch of example effects here: downloads.popcornfx.com/PaintBrushTest.pkkg
EDIT: I messed up the initial upload, please re-download if you already grabbed it.
I've kept the different iterations, they might be helpful.
You'll find two sets of effects:
- FX_TestPaintbrush_v***.pkfx : effect that's just paintbrush particles (either one paintbrush particle that you move around to paint for the first effects (v1-v3), and paint spray particles for the last one (v4)).
- FX_TestPaint_v***.pkfx : effect that spawns paint particles on a mesh, and instantiates the external paintbrush effect in an effect backdrop to test it works as expected. See this as the "test rig" for the whole effect.
Now, the first versions v1 to v3 use a single brush particle setup in localspace that you move around and it "paints" the invisible paint particles that it touches. It seemed simpler to start with that, but in the end not really, so.. I've kept these in if you're curious, but you'll probably just want to skip right to the ones that paint with a spray, which are the v4 versions.
Single localspace particle painting (v1-v3):
Spray painting (v4):
When the amount of paint particles revealed go above 50% of the total, all particles appear.
within the v4 spray version, there are various variations as well, due to some limitations of v1.11 (more on that later)
Also, up until the _v3b version, all the first ones do not perform any kind of switch once everything has been painted, they just focus on the painting and the detection of the paint ratio.
v3b shows a basic approach where each particle triggers an event that spawns a layer in LayerGroups to make the switch, but this produces a massive framerate spike (more on that later), v3c shows an alternate approach using a trail evolver that performs the switch unnoticeably.
To test the effects, go in the Backdrops > 3D layers, and select the "PaintBrush" backdrop, then move it around in the viewport, this will reveal the particles on the mesh.
you can also move the instance of the effect around to move the mesh under the paintbrush, by selecting the root "Spawner" folder and moving the instance around in the viewport.
The paint particles layer uses a layer script to automatically adjust the number of particles spawned based on the mesh surface and a "Density" attribute, meaning you'll have less particles for smaller meshes, and more particles for larger meshes. This is clamped to a max value, to avoid getting out of control depending on the mesh you're binding to the attribute sampler in the game engine.
Detecting the paint ratio
The first set of effects with the single-particle brush use two spatial layers, the second set of effects with the spray have to use 3, and they work a bit differently, I'll only cover the second set with the sprays.
The main problem of this effect is to make the thousand of paint particles know when they should all switch to the state "all painted".
We could either:
Option 1: Make them all aware of each other, and have them query the state of all their siblings, so that when more than 'x'% are painted, all of them conclude they should switch themselves to the "painted" state.
Option 2: Use a single intermediate helper particle, that takes care of querying all the paint particles, and decides wether or not they should switch to the full painted state or not. If it decides they should, it writes that information in another spatial layer, that gets queried by the paint particles.
We'll go for option 2 for performance reasons, as it's much faster, even though requiring an additional spatial layer (to give you an idea, for 5000 paint particles, option 1 runs at 20 fps on my machine (i7, 12 HW threads), while option 2 runs at over 600 fps in the editor)
To transmit that information, we can't just make a regular query based on the position of the particles.
For this, we kind of "abuse" spatial layers to store information at a specific location, that has nothing to do with an actual 3D position. For example, we'll say that the boolean flag representing wether or not the particles should switch to the full painted state is going to be stored at coordinates float3(-42, 123, -8) (which represent absolutely nothing, it's just a randomly picked 3D location where we'll store the data we need, think of it as a dog hiding a bone in the garden at some random coordinates. That bone is the information we need, and all our paint particles know they should grab it through a query at coordinates float3(-42, 123, -8).
This location can't be hardcoded in the effect though, because it would then mean that all instances of the paint effect would use the same location to store this information, and they would all switch to the full painted state as soon as one of them switches. This would effectively make the information "global", whereas we'd like it to be "per instance" of the effect.
So, in all example effects up to v4b, the location this data is stored at is exposed as an int3 attribute "_InternalBrushKey", so it's up to you, in the game engine, to feed the same value to the instance of the paintbrush effect and to the instance of the paint effect, so they can properly communicate, and to set different coordinates between different instances so they don't conflict.
the spray effects work slightly differently and their paintbrush FX doesn't need those coordinates anymore, so you only have to set them once for the main paint effect.
effects v4b and v4c show two variants that do not need the attribute at all.
These go through a first invisible dummy particle, whose whole purpose is to generate a random 3D coordinate for the spatial layer key, then to trigger the actual layers of the effect, which will be able to grab the same values by accessing "parent.SpatialLayerKey". However, there are some other limitations. Attribute samplers don't work in v1.11 for effect v4b (see the comments in the effect), but will work in v1.12, and localspace doesn't work in effect v4c, but attribute samplers work.
Dissection of effect "FX_TestPaint_v4.pkfx"
1 particle per instance of the effect. This is the particle that makes the decision wether or not the effect should switch to the fully painted state
many particles per instance of the effect. These are the paint particles lying on the mesh, that query the brush particles to know when they should paint themselves, and that switch to the "PaintDone" layer when the effect switches to the fully painted state
cheap "do nothing" version of the paint particles.
- PaintLookup : Shared with "FX_TestPaintbrush_v4.pkfx", this is where the spray paint particles insert themselves, it's queried by the paint particles on the mesh to know if they're close enough to a brush particle to become painted.
The insertion location is the actual 3D position of the brush particles
- PaintComMany : Local to the paint effect, this is a communication layer where the many paint particles insert themselves. It stores their state: wether they are painted or not. They all insert at the same location for a given instance, which is that special "key". It is queried at that location by the single helper particle of the "PaintCommunication" layer with a 'sum' query to count the percentage of painted particles.
- PaintComFew : Local to the paint effect, this is a communication layer where the single helper particle of the "PaintCommunication" layer will insert the flag telling wether or not the effect should switch to the fully painted state. It is queried by the many paint particles to know if they should make the switch or not
Switching layers once everything's painted
There are two main ways to do this once a paint particle knows it should switch.
First one is creating the "PaintDone" layer as a regular layer in 'LayerGrouns', then, in the original paint particle, do:
int shouldPaintAll = ... some condition ...;
This is what effect v3b does.
As mentioned above, it produces a massive framerate spike, because in the same frame, ALL the couple thousand paint particles will trigger the same number of couple thousand layer instantiations, each layer only spawning a single particle. And layer instantiations aren't batched in PopcornFX v1.x (we're adressing this in version 2.0).
To give you a concrete idea, here's a screenshot of the profile of a regular frame of the effect running.
(Horizontal is time, here the update jobs (the yellowish vertical area under the viewport framerate) takes less than 0.5 ms)
And here is the capture of the frame where the transition is made:
(Here the update jobs take more than 16ms, it's all spawning the "PaintDone" layer instances
So, to work around this, we can use a trail evolver instead of manually triggering, as trail evolvers DO batch child spawns, unlike triggering an event to spawn a regular layer.
The idea is to make a trail evolver that will spawn nothing, except on the frame where the transition needs to happen, where for each parent particle, it'll spawn a single child particle.
To do this, we'll need to setup the trail evolver to not be a regular distance-based or time-based trail, but to be entirely custom.
In the trail evolver, switching the "SpawnMetric" property to "Custom" allows to do that. The trail will now look into the field whose name is set in the "CustomSpawnMetricField" property to grab each frame the value it'll use to know how many particles to spawn.
For a distance based trail, that value is the distance the particle has moved during the frame, for a time-based trail, it's the frame time.
So in our case, we'll basically just set this field to 0 all the time, except on the frame where the switch should be made, where we'll set it to 1.
If we setup the "SpawnInterval" property of the trail evolver to 1 as well, this will make the trail spawn a single child particle on that specific frame.
I can't post a screenshot of the profiler for the frame where the switch is made using that method because.. The difference is so unnoticeable that I couldn't spot it / capture it amongst the other regular frames, even after trying to catch it for 5 minutes.
Here are the performance figures when making the switch, ~0.3 ms before switching to full painted mode (all queries and logic still done), down to ~0.045 ms after the switch.
This trail trick is also used in the v4b effect, where I used trails to replace all layers and force them through a single root particle to auto-generate the insertion location.
effect v4c uses triggers but triggering layers in layer groups looses localspace information, so the v4b version uses trails to still be able to use localspace (but, as mentioned earlier, downside is it can't use attribute samplers...)
anyway, I've commented a fair bit of the scripts in all those effects, so you'll have more details there.
I might have skipped explaining something important, so don't hesitate to ask if something's not clear.