Game Dev: Using slope detection to automate the horizontal to vertical blend
If you searched for ‘how to know if a point on a spline is on a slope or not’, ‘how can you detect if a given part on your spline is a river or a waterfall’ or ‘how to automate and calculate water movement speed of a spline’, then you’ve come to the right place. Although, you can use the math for pretty much anything you need as the result of the calculation is a float (percentage) between 0 and 1.
Chapters:
- The Setup
- Is Point On a Slope
- Calculate The Horizontal to Vertical Blend
The shown code is (adapted) Unreal C++ code. You will need to tweak some things (add proper UPROPERTY’s and such) and add other functionalities to make it work. This is purely to elaborate the thought process.
1. The Setup
Before we start, we will need to cut up our spline so we have equal distance points along the spline. We’ll call these ‘contact points’. These contact points are a struct which will hold various information, such as: position, direction, … and whether it’s on a slope or not.
struct FContactPoint
{
FVector position; // position of the point along the spline
... // other properties you'd like to add
int isSlope; // whether or not the point is on a slope
};
When creating your contact points you’ll iterate over all the segments found on your spline. With each segment you will then add a contact point over a given distance. The closer the distance between each contact point, the more accurate everything will be. But… you’ll also do more calculations which is less performant! A good starting point would be 3 Unity Units or 300 Unreal Units.
// d = to eliminate double points, with same start or end position
// float maxStep = distance between each contact point 300 (for unreal)
// i = index of segment
float d = (i%2) == 0 ? 0 : maxStep;
FVector normal = segments[i].end - segments[i].start;
const float maxDistance = normal.Size();
normal /= maxDistance; // normalized
FVector p = segments[i].start;
// Travel along the normal until we reached the end of the segment
while( d < maxDistance)
{
// Create contact point
// Whatever properties you've added in your struct can be
// calculated and added here. Except for the isSlope.
// We will do that later. Which is why it is set to 0 for now.
FVector pos = p + normal * d;
contactPoints.Add(FContactPoint{pos, 0});
d += maxStep;
}
2. Is Point On a Slope
To calculate if a point is a slope or not, we will use the dot product. First, we calculate the direction by subtracting the position of the next point with the previous point. Then we can find the dot product using the direction vector and a down vector.
You could take the absolute value of the dot product if you just want to know if it is on a slope or not (and return 0 or 1). And while you’re at it, change the return value from an int to a bool. The reason we did not do this, is because we wanted to know the direction of the slope. Are we going upwards, or downwards? At the time we needed this information to spawn an object at a particular location.
We will use a slope threshold to determine how steep a slope needs to be to be considered a slope. The smaller the number the less steep it needs to be to be considered a slope.
int IsContactPointOnSlope(int index, const TArray<FContactPoint> contactPoints)
{
// TArray<FContactPoint> is an array of our struct which contains a
// position, direction, ... and whether it's on a slope or not
if(contactPoints.Num() == 0) return 0;
int prevIndex = FMath::Max(0, index - 1);
int nextIndex = FMath::Min(contactPoints.Num()-1, index + 1);
FVector direction = (contactPoints[nextIndex].position -
contactPoints[prevIndex].position).GetSafeNormal();
// not Abs if you want to know the direction of the slope
float slope = FVector::DotProduct(direction, FVector::DownVector);
// public float SlopeThreshold = 0.8f, adjust to your liking
// 1 = slope -> from horizontal to down vertical
if(slope > 0 && slope >= SlopeThreshold) return 1;
// -1 = slope -> from horizontal to up vertical
if(slope < 0 && slope <= -SlopeThreshold) return -1;
// 0 = not slope -> horizontal
return 0;
}
The image below illustrates why we need the previous and the next point to determine if the current point is on a slope or not. If we were to take the dot product from a and x instead, then either x would not be considered on a slope or we would need to change the SlopeThreshold to a smaller number. This, however, is a bit dangerous, because a spline can have a lot of small curves which may not be intended as a slope.
You could say, well then why not use x and b to determine x (instead of a and x). So, use the current and next point to determine the current point. Well at the bottom of a slope, you want the last point of the slope to still be considered a slope, right? If we calculate the dot product of a point that is on a slope and one that is not (meaning they are approximately on the same horizontal line), then the current point will not be considered a slope as the dot product will be near 0 and well below the SlopeThreshold.
Now that we know how to calculate if a point is on a slope or not, we can add the value to our array of ContactPoints.
// Calculate if contact point is horizontal or on a slope
if(contactPoints.Num() > 0)
{
for(int i = 0; i < contactPoints.Num(); i++)
{
contactPoints[i].isSlope = IsContactPointOnSlope(i, contactPoints);
}
}
3. Calculate The Horizontal to Vertical Blend
Each point should now already hold the information whether it is on a slope or not. This information should only be set once the spline has been created. No need to keep calculating it. To get the horizontal to vertical blend we will need to calculate the slope ratio at runtime. This is because the point from which you calculate from is most likely to change. Why else would you want to calculate the ratio? Maybe to use it for changing the colour (or other value) of the spline. This could be done once, when it’s been created.
// int AmountOfNeighboursToCheck = 2, means 2 neighbours on each side
// the higher the number, the more precise the return value will be
if(AmountOfNeighboursToCheck > 0 && contactPoints.Num() > 0)
{
float slopeCount = 0;
float counter = 1;
// closestPoint = current closest contact point
if(FMath::Abs(closestPoint.isSlope) == 1)
++slopeCount;
for (int i = 1; i < AmountOfNeighboursToCheck; ++i)
{
int nextIndex = index + i;
int prevIndex = index - i;
if(nextIndex < contactPoints.Num())
{
if(FMath::Abs(contactPoints[nextIndex].isSlope) == 1)
++slopeCount;
++counter;
}
else
{
// if we are at the end we take the previous value
// to add to the weight of the ratio
if(FMath::Abs(contactPoints[nextIndex-1].isSlope) == 1)
++slopeCount;
++counter;
break;
}
if(prevIndex >= 0)
{
if(FMath::Abs(contactPoints[prevIndex].isSlope) == 1)
++slopeCount;
++counter;
}
else
{
// if we are at the first point we take the previous value
// to add to the weight of the ratio
if(FMath::Abs(contactPoints[prevIndex+1].isSlope) == 1)
++slopeCount;
++counter;
break;
}
}
// return amount of slopes divided by amount of calculated points
return slopeCount/counter;
}
// if we don't want to check any neighbours
// and we just want the value of the closest point
return FMath::Abs(closestPoint.isSlope);
The accuracy of your return value will depend on how many neighbours of your point you want to include in the calculation. Using 2 neighbours means you will divide your slope count with 5 (= the current point + 2 neighbours on each side). Dividing a number that is no bigger than 5 by a total of 5, will result in a semi-precise ratio. Certainly enough to be able to interpolate between whatever you need.
If we take the example of a river to waterfall ratio, we can then use this return value to blend between the sound of a river or a waterfall at runtime. We can also use it to calculate the speed at which the water needs to move when we initialize the spline. Knowing the position of the points, we can spawn splash particles and splash sounds at the bottom of the waterfall. We do this by taking the last known 1 value or the first known -1 value of the array. Think about it.