Arc length parametrization: Hermite spline [part 2]
mathematics / gamedev
Definitions
Bezier curves
I won’t give you a full overview of Bezier curves here as there is too much to talk about, read [2] for a quick jump start. But as we will use them, I should at least give some basic definitions that we can refer to.
The Bezier curves are a family of parametric curves that are wildly used in computer graphics. An order
Then an order
It is often desirable to work with a polynomial expansion of
with the coefficients
This can be obtained by binomial expansion of the
Hermite splines
Cubic Hermite splines are used a lot as an interpolation tool, especially in game programming where they can represent agent or camera trajectories, or be used for procedural mesh generation to name a few common examples. In this context, they are often referred to as csplines. I happen to use them quite a lot to interpolate between colors too, I coded a whole daylight cycle / atmosphere system based on these a few years ago.
They are piecewise smooth curves whose sections are described by cubic polynomials. A cubic Hermite spline is fully specified by the
Let
This transformation maps a parameter value
where the
As stated previously, we will restrict ourselves to the family of cardinal splines, whose inner tangents
The parameter
By expansion of
This means that our segments can in fact be expressed as Bezier curves! Indeed, the following set of Bezier control points:
when substituted into
I find it more useful to define Hermite splines in terms of Bezier patches as it opens the way to useful algorithms such as De Casteljau splitting, and allows to reuse an existing Bezier spline implementation (if you happen to have one in your codebase, which most of the game devs do). So that’s a design choice I’m going to make in the following implementation.
Arc length parametrization?
Let’s see if we can find a closed-form formula for the arc length of an order
We can already stop right there, because for arbitrary control points, the norm in the integrand will evaluate to the square root of a degree
This doesn’t mean however, that we can’t evaluate or approximate this beast numerically. The spline does have a length of course, it’s just not expressible in terms of elementary functions. If we can compute a series of values
Implementation
You can find my original C++ implementation in my personal GitHub here, and an example on how to use it here. This is part of a library project called Kibble that I use mainly for game development. This implementation has much more features than what I’ll cover in this article, for example, it handles the computation of first and second derivatives along the spline, De Casteljau splits and can perform accurate length estimations. So it’s a beast, but a well documented one.
The demo I’m showcasing today will be done in Python with numpy
. numpy
shares a lot of spirit with Matlab, and its constructs tend to hide some loops as we address vectors and matrices as a whole. So be aware of this conceptual leap if you’re coming from the lands of C / C++ / Rust / Java.
We will start by coding Bezier splines that we’ll then use as segments in a Hermite spline implementation. The arc length parametrization will be handled by a wrapper around a Hermite spline.
Choices
We will consider Hermite splines defined using
As the tangents
Cubic Bezier spline
First, we should write code for our Hermite spline segments. Each segment of a Hermite spline can be defined as a cubic Bezier spline as we saw previously. We are going to define a generic Bezier spline in this section, and when the time comes to use it, we are going to feed it four control points so it’s always cubic (remember that the order of a Bezier spline is the number of control points minus one). Thanks to
class BezierSpline:
def __init__(self, control_points):
self.control_points = control_points
self.calculate_coefficients()
The calculate_coefficients()
function will take care of computing the
def calculate_coefficients(self):
self.coefficients = np.zeros(self.control_points.shape)
N = self.control_points.shape[0]
prod = 1
for jj in range(N):
# Product from m=0 to j-1 of (n-m)
prod *= N - jj if jj > 0 else 1
# Weighted sum from i=0 to j of the control points
ii = np.array(range(jj+1))
factor = np.power(-1, ii+jj) * prod / (scipy.special.factorial(ii)
* scipy.special.factorial(jj - ii))
self.coefficients[jj, :] = np.dot(
factor, self.control_points[0:jj+1])
It looks heavy, but it is a direct implementation of
def sample(self, tt):
N = self.control_points.shape[0]
tpow = np.power(tt[:, np.newaxis], np.array(range(N)))
return np.matmul(tpow, self.coefficients)
The np.newaxis
and np.matmul
shenanigans allow us to feed an array of parameter values as tt
, so we only need to call this function once. But under the hood it will evaluate the power series tt
, that’s all. You will need to modify the code a little bit if you want it to work with scalars as control points. And we’re done for this part! Let’s build and sample a cubic Bezier spline to test this:
def main(argv):
control_points = np.array([
[0, 0], [0.5, 5], [5.2, 5.5], [3, 2.2]
])
B = BezierSpline(control_points)
tp = np.linspace(0, 1, 50)
rp = B.sample(tp)
# [Plot stuff]
The plotting code was omitted, but you can find it in the full source code (link below). This is what we get:
which is exactly what we expect. The curve passes through the first and last control points, and is “attracted” to the control points in the middle. More precisely, if you draw a line between the control points
Because of the way we defined the Bezier spline, you could feed it control points in 3D space (or even 5D if you ascended to a higher plane of existence) and it would work the same. Neat. But in practice, you will need to interpolate other things than just vectors, quaternions come to mind. In this case, you will have to adapt your code to perform a Slerp for example, read [1] for a detailed explanation on how to construct a so-called quaternion curve.
Cubic Hermite spline
Now, we can use our BezierSpline
class as a building block for Hermite splines. We will restrict ourselves to the cardinal splines family that we discussed in the first section, which means that we use
- The control points which the spline must pass through
- The unconstrained tangents at both ends
- The tension parameter
Our __init__()
method should calculate the tangents at each control point (except at the beginning and end of the curve, where they are manually specified), and also initialize a BezierSpline
object for each segment:
class HermiteSpline:
def __init__(self, control_points, tension, free_tangents):
self.control_points = control_points
self.calculate_tangents(tension, free_tangents)
self.calculate_segments()
def size(self):
return self.control_points.shape[0]
The tangents are stored in a numpy array as usual. To calculate the (inner) tangents, we just use the formula
def calculate_tangents(self, tension, free_tangents):
# Initialize start and end tangents
self.tangents = np.zeros(self.control_points.shape)
self.tangents[0] = (1 - tension) * free_tangents[0]
self.tangents[-1] = (1 - tension) * free_tangents[1]
# Formula for a generic cardinal spline
for ii in range(1, self.size() - 1):
self.tangents[ii] = (1 - tension) \
* (self.control_points[ii+1] - self.control_points[ii-1])
Because we are using segment intervals of constant size, we have that
Also note that the free tangents get multiplied by
And each segment is calculated according to
def calculate_segments(self):
self.segments = []
for ii in range(self.size()-1):
bezier_control_points = np.array([
self.control_points[ii],
self.control_points[ii] + self.tangents[ii] / 3,
self.control_points[ii+1] - self.tangents[ii+1] / 3,
self.control_points[ii+1]
])
self.segments.append(BezierSpline(bezier_control_points))
Technically, for each segment, we are defining four control points for the underlying Bezier spline, two of which are successive control points of the Hermite spline, and the other two in the middle are constrained by the tangents. Don’t get mixed up between “Hermite control points” on the one hand, and “Bezier control points” on the other.
Now, this is where things get wild, and I apologize for that in advance. Our Hermite spline admits a parameter
- Find the segment index
corresponding to the value of - Calculate the local space parameter
as a function of and
Given that there are
with
To calculate
Now, because we are using numpy
for this demo, we must make this work for arbitrary arrays of parameter values xx
. So we write a helper function that takes in xx
as an input, and spits out one pair xx
:
def to_local_space(self, xx):
# Find segment index corresponding to each value of xx
tclip = np.clip(xx, 0, 1)
idxs = np.clip(np.floor((self.size()-1) * tclip), 0, self.size() - 2)
# Convert xx to local parameter value
tt = xx * (self.size()-1) - idxs
# Return an array that aggregates segment indices and local parameter value
return np.dstack((idxs, tt))[0]
The sample()
function of the BezierSpline
expects an array of
def sample(self, xx):
# Group local segment parameter values by index
loc = self.to_local_space(xx)
unique = np.unique(loc[:, 0], return_index=True)
seg_tt = np.split(loc[:, 1], unique[1][1:])
# Sample each segment
return np.concatenate([
self.segments[int(idx)].sample(tloc)
for idx, tloc in zip(unique[0], seg_tt)
])
Finally, we concatenate all the sample values returned by each segment. I know it’s a tough one to digest (writing it was no less of an arduous task), but take your time, add a few print
statements here and there, and you’ll see that it makes sense. The C++ implementation I mentioned at the beginning of the section is much more straightforward in that regard (albeit way lengthier in general), so maybe take a look at it.
Still with me? Let’s display a Hermite spline:
control_points = np.array([
[0, 0], [0.5, 5], [5.2, 5.5], [3, 2.2]
])
free_tangents = np.array([
[0,3], [-4,0]
])
H = HermiteSpline(control_points, 0, free_tangents)
tp = np.linspace(0, 1, 50)
rp = H.sample(tp)
# [Plot stuff]
which should output:
I chose the same values for the control points as before, not in an attempt to confuse you even more, but to make more obvious the difference with a Bezier spline: the Hermite spline passes through all of its control points. This one has three segments, all of which begin and end at a Hermite spline control point. As you can see, the inner control points of each segment (shown in green) prescribe a tangent at each segment end. Let’s plot Hermite splines using the same set of control points and free tangents for multiple values of
It’s as if the curve had some elastic quality to it, and we were pulling it from one end, the other being attached, hence the name “tension” for
Arc length parametrization
As stated earlier, there is no closed-form expression for the arc length function of a Hermite spline, so the next best thing to do is to sample the spline at multiple locations and approximate the arc length numerically at these locations. Then, we can use a binary search algorithm to invert the arc length table numerically, giving us an inverse arc length table that we will use as a lookup table (LUT) for uniform sampling.
First, let’s define another class that will wrap a HermiteSpline
and allow uniform sampling:
class ArclenHermiteSpline:
def __init__(self, spline, samples):
self.spline = spline
self.calculate_lengths_iterative(samples)
self.invert_arc_length(samples)
The samples
constructor argument specifies the number of sample points along the spline used for the calculation of the arc length table.
Getting an arc length table
There are multiple ways to approximate the arc length along the spline. The easiest is to sample the spline densely enough, and compute pairwise Euclidean distances between consecutive points. Then, a prefix sum of these distances is performed, and we obtain the cumulative distance at each sample point. This can be done succinctly in Python using array slices and numpy.cumsum()
:
def calculate_lengths_iterative(self, samples):
# Sample the Hermite spline
tp = np.linspace(0, 1, samples)
rp = self.spline.sample(tp)
# Calculate Euclidean distances between consecutive pairs of points
distances = np.zeros(tp.shape)
distances[1:] = np.linalg.norm(rp[1:, :] - rp[:-1, :], axis=1)
# The arc length table is the prefix sum of these distances
self.arc_length = np.cumsum(distances)
If the sample points are densely packed along the curve, the lengths will be approximated correctly. If you need more precision, consider using a De Casteljau split algorithm on the spline segments. This is possible because we defined segments as Bezier splines! Maybe I’ll write about this in a future article.
Inverting the arc length table
So we have a table self.arc_length
containing the arc lengths
If we want to invert
def binary_search(self, target, last_idx):
lb = last_idx
ub = self.arc_length.shape[0]
idx = lb
while lb < ub:
idx = lb + (ub - lb) // 2
if self.arc_length[idx] < target:
lb = idx + 1
else:
ub = idx
return idx - 1 if self.arc_length[idx] > target else idx
Basically, we initialize a lower bound lb
and an upper bound ub
to cover the whole search interval, the index we’re looking for being somewhere inbetween. Then we iteratively narrow down this interval until lb
and ub
converge to the same integer. At this point, we know that this integer is either the index we’re looking for, or the integer just above that, and we decide based on an ultimate comparison test.
This algorithm will be called iteratively for monotonically increasing values of
Let’s now write a function that takes in a normalized arc length parameter
def arclen_remap(self, s_bar, last_idx):
# Arc length from normalized arc length
ss = np.clip(s_bar, 0, 1) * self.arc_length[-1]
# Get the index of the largest arc length value that is
# smaller than our target value ss
idx = self.binary_search(ss, last_idx)
max_idx = self.arc_length.shape[0]-1
if idx == max_idx:
return 1, idx
# The distance covered in the LUT by the binary search
# algorithm is a measure of the inverse of the arc length
len_before = self.arc_length[idx]
len_after = self.arc_length[idx+1]
len_segment = len_after - len_before
frac = (ss - len_before) / len_segment
xx = (idx + frac) / max_idx
return xx, idx
First we compute self.arc_length
). Then we find the index idx
in the arc lengths table thanks to our binary_search()
function. We could return idx / max_idx
which corresponds to the fraction of the table size covered by idx
, but this is too crude of an approximation, and we can do better that that.
We know that our target length self.arc_length[idx]
) and self.arc_length[idx+1]
) by construction. We can then compute a fractional part frac
between (idx + frac) / max_idx
as our approximation for
We also take care of returning idx
for the next binary search iteration to use.
Finally, we can write the invert_arc_length()
used by the constructor, to build the arc length lookup table:
def invert_arc_length(self, samples):
last_idx = 0
self.lut = np.zeros(samples)
# Build the lookup table iteratively
for ii in range(samples):
s_bar = ii / (samples - 1)
self.lut[ii], last_idx = self.arclen_remap(s_bar, last_idx)
# Repeat the last value in order to avoid an out of bounds
# error during sampling
self.lut = np.append(self.lut, self.lut[-1])
That’s pretty straightforward: for multiple values of arclen_remap()
function to compute the corresponding values of the curve parameter
All that is left to do is write a sample()
function that will remap an array of
def sample(self, s_bar):
# Get the values xx of the curve parameter corresponding
# to the normalized arc lengths s_bar
sclip = np.clip(s_bar, 0, 1)
max_idx = self.lut.shape[0] - 2
idxs = np.floor(sclip * max_idx).astype(int)
alpha = max_idx * sclip - idxs
xx = (1-alpha) * self.lut[idxs] + alpha * self.lut[idxs + 1]
# Sample the spline
return self.spline.sample(xx)
For all values of
Then, the corresponding self.lut[i]
) and self.lut[i + 1]
), and we use a simple lerp to obtain a better approximation:
Because we are querying self.lut[i + 1]
in this function, care must be taken so as to avoid an out of bounds error. As a simple fix, we repeated the last value invert_arc_length()
function, so
Let’s see how it looks:
I modified the control points a bit as compared to the previous figure, so the last segment is shorter and the last bend tighter. This makes more obvious the uniformity defect of the default parametrization. You can see on the other hand that sampling the curve with an arc length parametrization gives equally spaced sample points.
This concludes this article. If you made it here, congratulations! I know it’s a tough subject to wrap one’s head around.
Sources
- [1] Myoung-Jun Kim, Myung-Soo Kim, and Sung Yong Shin (1995). “A General Construction Scheme for Unit Quaternion Curves with Simple High Order Derivatives”
- [2] Bezier Curves from the Ground Up - Jamie Wong’s article on Bezier curves, with awesome animations.