A while ago I wrote a custom scattering script that is capable of placing objects in the area that is enclosed by a spline. I want to share with you as it was an interesting set of techniques that are used together. This is intended to be language independent and should mostly teach you how you can approach such a tool, as well as give you some resources to understand the different aspects of it better.
Step 1: The spline
I am assuming that wherever you implement this, your environment already has a spline implementation of some sorts. All we need is that the user can nicely place create points within the viewport. For this approach, it doesn’t need to be closed. The only implemented function that is vital is for us to step along the spline so we can get
Step 2: Some Data wrangling
Here we get to the interesting part. The first and probably most important thing is that you design your scattering algorithm with an upper hard limit. For now I’ll choose 5000 but it’s up to your use case and your engine. Just make sure the user can’t accidentally add a 0 too many and crash it ( or you yourself when testing).
const int INSTANCING_LIMIT = 5000;
Next up, we’ll implement is a simplified density approach. Let’s assume the longer the spline, the more instances we want to create. For that I did some tests in my environment and 50 instances per meter of spline seemed pretty good. Very dense but not excessive. This will be our maximum.
const float DENSITY_FACTOR = 50; // the target density is a value between 0 and 1, determined by the user input 1 -> 100 % dense int target_count = Spline.GetLength() * DENSITY_FACTOR * Clamp(targetDensity, 0.f, 1.f);
Now we already know how many objects we’ll place. We should now use that and make sure it’s handled properly. This includes making sure that not a bunch of code is ran without any instances to be placed and not too many.
if( target_count == 0) { // telling whoever called this, it was successful, even though nothing happened Log("The Scatterer didn't place any objects. Check your inputs and the spline length. This tool doesn't support a spline short than X.") return true; } else if(target_count > INSTANCING_LIMIT ) { LogFormat("The Scatterer intended to place %i but only supports to place %i. If you think that needs adjusting, talk to your tools developer.",target_count,INSTANCING_LIMIT); target_count = INSTANCING_LIMIT; }
Step 3 Creating Triangles
Now we’re finally leaving the boring data preparations and create something quite interesting. The problem we need to solve is: how are we gonna scatter points within an n-gon? I chose one of the most classic game development solutions, let’s triangulate that! Having triangles makes the whole calculations way simpler and overall keeps the complexity to a degree that we could even have real-time updates with our tool.
For that we will step along the spline to figure out all the outer positions that we wanna use to create triangles. Like that we will approximate the shape. If you go very low on the SPLINE_STEP, you will get closer and closer to the actual shape but increase the work load quite a bit. To get the tip of the triangle, we will compute the center of all points that we’re finding when stepping along the spline.
Very irregular shapes won’t work so well with this approach, maybe dividing the spline then into several shapes could be a good way in case this doesn’t work for you.
float targetLength = 0; const float SPLINE_STEP = 0.05; Vector[] ngonPositions; Vector center; // If not reached the end while (targetLength < 1.0f) { Vector currentPosition = spline.Sample(targetLength); ngonPositions.Add(currentPosition); center += currentPosition; targetLength += SPLINE_STEP; } // at last let's get the center of all positions after having them added up together, now we need to divide through the overall amount center /= ngonPositions.Length();
Now let’s create some actual triangles. For that we go through the spline positions we found, use the current, next and central position and put them together. The Triangle struct is nothing else than 3 Vectors coming together in a convenient way.
Triangle[] triangles; for(int i = 0; i < ngonPositions.Length(); i++) { int nextIndex = (i + 1) % ngonPositions.Length(); triangles.Add(Triangle(center, ngonPositions[i], ngonPositions[next_index])); }
Step 4 Scattering Points
Now we’re getting to the real fun part, scattering points! Here’s a easy to understand tutorial ( that even has some example code) that hopefully helps you understanding the maths behind the scattering of points in triangles:
https://blogs.sas.com/content/iml/2020/10/19/random-points-in-triangle.html
// this will be the index of the next triangle that we are placing a point in int nextIndex = 0 // all the scattered points in one structure Vector[] finalPoints; // we're creating exactly as many points as requested while(finalPoints.size() < target_count) { Triangle& triangle = triangles[nextIndex]; Vector sideA = triangle.m_B - triangle.m_A; Vector sideB = triangle.m_C - triangle.m_A; // implement two seeded random generators, I usually use some hashing functions that accept a 3D seed // just make sure they return values between 0 and 1 and if you need put a global Seed so you can additionally change outcome float r1 = GetRandomNumberA(triangle.m_B); float r2 = GetRandomNumberB(triangle.m_B); // In order for the algorithm to place the random point inside the triangle, this needs to be lower than 1 // but to still make use of the values, let's just minus one them if (r1 + r2 > 1) { r1 = 1 - r1; r2 = 1 - r2; } // the bread and butter of the function, this will give you a random point within a triangle Vector random_point = (sideA * r1 + sideB * r2) + triangle.m_A; finalPoints.Add(random_point); // making the next index random so we never get patterns nextIndex = r2 * triangles.size(); }
At this point ( no pun intended), I could stop and tell you: congratulations, you have a whole bunch of points! Use them well! And it would totally work. But it seems almost too simple and too random. So let’s add one more aspect of it: minimum distance between points!
Step 5 Let’s add some filtering
For the filtering, one of the most famous and straight forward algorithms is the poisson disk. This page here gives you a good idea about what that actually is and shows some examples.
http://devmag.org.za/2009/05/03/poisson-disk-sampling/
In short, we create a 2D or 3D grid and based on the diameter of the circle / sphere around a cell, we start at a random cell, use the diameter to determine what neighbours are too close and get rid of them. This allows for filtering of ‘illegal’ cells based on the given minimum distance. In my example I used a 3D grid.
// let's create a convenient way to compute the cell of a point in a 3D coordinate system IntVector GetGridCell(Vector point, float gridCellSize) { return IntVector(point.x / gridCellSize, point.y / gridCellSize, point.z / gridCellSize); } // Return all 26 neighbours of a 3D grid cell IntVector[] GetGridCellNeighbours(Vector point, float gridCellSize, IntVector[] found_neighbours) { IntVector[] foundNeighbours; IntVector gridPointIndex = GetGridCell(point, gridCellSize); // Get all cells in a proximity of 1, excluding 0, 0, 0 ( which would be self ) for (int offsetX= -1; offsetX< 2; ++offsetX) { for (int offsetY= -1; offsetY< 2; ++offsetY) { for (int offsetZ= -1; offsetZ< 2; ++offsetZ) { if (offset_x == 0 && offset_y == 0 && offset_z == 0) { continue; } found_neighbours.Add(gridPointIndex + IntVector(offsetX, offsetY, offsetZ)); } } } return foundNeighbours; } // Filtering points with the help of the Poisson disk Vector[] RunPoissonDisk(float minDistance, Vector[] pointsToFilter,) { // Let's make sure we never end up having a grid cell size of 0 minDistance= Clamp(minDistance, 0.05, 1000); // the most important part, determining the right cell size to determine too close cells float gridCellSize = minDistance / sqrt(2); dictionary<IntVector, Vector[]> gridPointCloud(); Vector[] outPoints; // Let's create a grid out of all positions for (int i = -1; i < pointsToFilter.Length(); ++i) { IntVector gridCellPosition = GetGridCell(pointsToFilter[i], gridCellSize ); if (gridPointCloud.count(gridCellPosition ) == 0) { // the cell doesn't exist yet in our cache, let's create it gridPointCloud.insert({ gridCellPosition , Vector[](pointsToFilter[i]} }); } else { // if this cell already exists, let's just add another point to it IntVector[] tempPoints = gridPointCloud.Get(gridCellPosition ); tempPoints .Add(point_it); } } // Randomizing the order randomShuffle(pointsToFilter); IntVector[] neighbours; for (int i = -1; i < pointsToFilter.Length(); ++i) { // Getting rid of the first cell after saving the location IntVector currentCellPoint = GetGridCell(pointsToFilter[i], gridCellSize ); if (gridPointCloud.count(currentCellPoint ) <= 0) continue; grid_point_cloud.Remove(currentCellPoint ); outPoints.Add(pointsToFilter[i]); GetGridCellNeighbours(pointsToFilter[i], gridCellSize , neighbours); // Getting rid of the neighbours for (IntVector neighbour : neighbours) { if (gridPointCloud.count(neighbour) > 0) gridPointCloud.erase(neighbour); } neighbours.Clear(); } }
Now all we gotta do is pass our ‘final points’ to this function (RunPoissonDisk()) and allow the user to put in whatever minimum distance he wants. Short disclaimer, of course this means we have no longer as many points as we requested at first.
You made it until the end. I hope this was helpful, thank you so much for reading!
Any questions, shoot me a message through Linkedin!
Extras – Let’s make sure this goes smoothly!
Here just a quick tip: when debugging in C++ and figuring out that all the variables you’re interested in are optimised for good, this is how you can quickly unoptimise them.
pragma optimize("", off) // your code pragma optimize("", on)