Arc length parametrization: catenary [part 1]

mathematics / gamedev
If you are using mathematical curves as input to skinning for procedural geometry, or as paths followed by an agent or a camera in game development, you might have suffered the effects of nonuniform sampling along the curve. The input parameter of the function describing your curve is not, in general, a fraction of the arc length along the curve. This translates to weirdly stretched skins on your procedural model, or non-uniform movement along your paths. Today, we are solving this problem for a specific type of curves for which a closed-form expression exists. In the next part we will address the same problem for a more uncooperative class of curves.

General case

Arbitrary curve in Rn

Let a parametric curve C be described by a continuously differentiable and injective function γ:IRn for I=[a,b]R. Let tI be the curve parameter. Then, we can define the arc length of the curve up to the value t of the parameter as:

(1)s(t)=at|dγdu|du

The full length of the curve is obtained by integrating over the whole interval I:

(2)L=ab|dγdu|du

If we want to find the value of t such that the arc length s has a given value, then all we need to do in theory, is to compute the inverse function t(s). Then the function γt is parametrized in terms of the arc length. If we need the value that γ takes exactly at the midpoint of the curve C, for example, then we calculate (γt)(L2). We can additionaly compose this function with ι:uLu, so that we just need to feed it the length fraction as a number in [0,1]:

(3)γγtι:[0,1]Rn

And the value of γ at the midpoint of C is simply γ(12).

Planar curves defined by function graphs

To set these ideas on a special case, let’s consider the curve obtained by graphing some arbitrary C1-function. Let a curve C be specified as the graph of a function γ:IR. Then all the points rR2 belonging to this graph are of the form:

(4)r(t)=[tγ(t)]

Then 1 takes the form:

(5)s(t)=atdr(u)dudu=at1+(dγdu)2du

If we’re looking for a closed-form expression, then 1 must be expressible in terms of elementary functions, and the expression must be invertible. This is not possible in general as 1 is often an elliptic integral, as you might suspect from seing that bad boy radical in the integral 5. And if it is not the case, then there is always the possibility that no closed-form expression exists for the inverse of s. In fact, there are only a handful of curves for which a closed-form arc length expression exists, and we’ll have a look at one of them in the following section.

On a side note, we have some adjustments to make to 4 if we intend to plot γ using the normalized arc length s[0,1]:

(6)r(t)=[(ti)(s)(γtι)(s)]

Arc length parametrization of the catenary

The Gateway Arch, courtesy of Robert Linder on unsplash.com.

A catenary is the shape assumed by a hanging chain or rope, supported only at its both ends, under the effect of its own weight. If you ever saw an electric cable hanging between two posts, or the Gateway Arch in St. Louis, you know what a catenary looks like. It resembles a parabola, but a more rounded version of it. I won’t prove to you why hanging cables assume this shape, but I encourage you to have a look at a proof involving variational calculus if you’re interested in physics, it’s worth it. We will assume that the catenary is given by the following general expression:

(7)γ(x)=acosh(xpa)+q

That’s right, a good ol’ hyperbolic cosine! Here, p, q and a are curve parameters. It is more practical, in terms of game development, to think of the anchor points P1 and P2 and the total length L as the input parameters to this problem, as it makes more sense to control them in an editor, rather than the curve parameters themselves. So p, q and a will be expressed later on as functions of P1, P2 and L.

Solving for the arc length

Let P1=(x1,y1) and P2=(x2,y2) be the position of the anchor points in the plane. We suppose that x1<x2 and y1y2. If it’s not the case, you can always swap P1 and P2 and reflect the curve about the midline. We also note v=y2y1 and h=x2x1. We suppose that the total length L of the catenary verifies the inequality L>P2P1 as the curve cannot be shorter than the straight line connecting the anchor points.

By the chain rule, we have that:

(8)dγdx=sinh(xpa)

Thus, according to 5, the arc length of the catenary is given by:

(9)s(x)=0x1+sinh2(upa)du=a0xpa1+sinh2(w)dw=a0xpacosh(w)dw=asinh(xpa)+c

And because s(x1)=0 we have a value for the integration constant:

(10)c=asinh(x1pa)

So the final expression for s is:

(11)s(x)=a[sinh(xpa)sinh(x1pa)]

And substituting x2 gives an expression for the total length L:

(12)L=a[sinh(x2pa)sinh(x1pa)]

Because we have the insolent good fortune to work with such a nicely behaved function, we can invert 11 to express x as a function of s:

(13)x(s)=aarcsinh(sca)+p

with arcsinh(u)ln(u+1+u2). And with this we can easily obtain an arc length parametrized γ using 3:

(14)γ=γxι

Determining parameters

If we want to find the value of a such that the catenary between P1 and P2 is of length L, then we need to solve the following equation:

(15)L2v2=2asinh(h2a)
Proof [click to expand]
By 7 and the definition of v we have: (16)v=a(cosh(x2pa)cosh(x1pa)) So using 12, the definition of h, and some hyperbolic trigonometric identities (difference of squares, cosh of the difference and double argument for cosh) we can expand L2v2 like so: (17)L2v2=2a2(cosh(x2x1a)1)=4a2sinh2(h2a) And by taking the square root on both sides we get 15.

Now, the issue with this one, is that it’s a transcendental equation in a, so it can only be solved numerically. A few iterations of Newton-Raphson will do the trick:

(18)f(a)=2asinh(h2a)L2v2 (19)dfda=2sinh(h2a)hacosh(h2a) (20)an+1=anf(a)dfda

Our update scheme 20 requires us to input an initial guess a0 for the value of a, and the convergence is quite sensitive so we would better not be too far off. In practice, I found that a0 can be selected by iteratively incrementing a by a geometrically growing step size until the sign of f(a) changes. This works becausef only has a single zero, as it’s monotonically decreasing. More on that later. Then, p and q are given by:

(21)p=12(x1+x2aln(L+vLv))
Proof [click to expand]
First, the total length of the curve 12 can be rearranged a bit, using some hyperbolic trigonometry and the definition of h: (22)L=2acosh(x1+x22p2a)sinh(h2a) Plugging P1 and P2 in 7 we get the following system: (23){y1=acosh(x1pa)+qy2=acosh(x2pa)+q Subtracting on both sides and by definition of v, we get: (24)y2y1=v=a(cosh(x2pa)cosh(x1pa))=2asinh(h2a)sinh(x1+x22p2a) Multiplying this by 1 written in a fancy way, we recognize 22 and a hyperbolic tangent : (25)v=2asinh(h2a)cosh(x1+x22p2a)cosh(x1+x22p2a)sinh(x1+x22p2a)=Ltanh(x1+x22p2a) And we can express p after some massaging: (26)x1+x22p2a=arctanh(vL)=12ln(1+v/L1v/L)=12ln(L+vLv) And from there, recovering 21 is a piece of cake.
(27)q=12(y1+y2Lcoth(h2a))
Proof [click to expand]
Summing on both sides of 23 (expand the proof for p) we get: (28)2q=y1+y2a(cosh(x1pa)+cosh(x2pa))=y1+y22acosh(x1+x22p2a)cosh(h2a) And multiplying the second term by a smart 1, we recognize 22 and a hyperbolic cotangent: (29)2q=y1+y22acosh(x1+x22p2a)sinh(h2a)sinh(h2a)cosh(h2a)=y1+y2Lcoth(h2a) which completes our proof.

Implementation

My original implementation is scattered across multiple C++ files in a project called Kibble, located on my personal GitHub account. You can have a look at this folder, more specifically the files named catenary.h/cpp and numeric.h/cpp, if you’re looking for a C++ implementation. There is also an example here on how to use the implementation. Kibble is a utility library I’ve been working on for some time, that features many software components I end up always reusing in my game development projects.

But I find it more practical to plot stuff in Python, so let’s have some good fun with numpy, scipy and matplotlib:

import numpy as np
from scipy import optimize
from matplotlib import pyplot as plt

First, we implement our catenary function from 7:

def catenary(x, a, p, q):
    return a * np.cosh((x-p)/a) + q

The arc length parametrization is handled by another short function that makes use of 13:

def arclen_remap(s_bar, L, a, p, c):
    return a * np.arcsinh((s_bar * L - c) / a) + p

We move on to determining the values of a, p, q and c from the input parameters. We will use scipy.optimize.newton() to find a by solving 15, so we must implement functions for 18 and 19:

def f(a, h, v, L):
    return 2 * a * np.sinh(0.5 * h/a) - np.sqrt(np.power(L, 2)-np.power(v, 2))

def fprime(a, h):
    return 2 * np.sinh(0.5 * h/a) - (h/a) * np.cosh(0.5 * h/a)

We also need an initial guess for the Newton-Raphson algorithm to work:

def nr_first_guess(ff, start_x, start_step, alpha):
    xx = start_x
    step = start_step
    yy = ff(xx)
    yy_prev = yy

    while yy * yy_prev > 0:
        yy_prev = yy
        xx += step
        step *= alpha
        yy = ff(xx)

    # Backtrack a bit
    return xx - 0.5 * step / alpha

This function iteratively increments the value of the argument xx fed to an input function ff. The steps themselves grow geometrically with a common factor alpha which is bigger than 1. We break from the loop when the sign of the function has changed. At this point, we know we’re past the zero of the input function, so we interpolate between the current and previous value of xx to output a more accurate value for the zero. This is a quick and dirty solution, you can get fancier with a binary search if you need to.

Then, using the previous functions, as well as formulas 21, 27 and 10, we are able to compute the values of all the parameters that the catenary() function expects as inputs:

def get_params(p1, p2, L):
    hv = p2 - p1
    m = p1 + p2
    def f_bind(a): return f(a, *hv, L)
    def fprime_bind(a): return fprime(a, hv[0])

    a0 = nr_first_guess(f_bind, 0.1, 0.01, 1.8)
    a = optimize.newton(f_bind, a0, fprime_bind)

    p = 0.5 * (m[0] - a * np.log((L+hv[1])/(L-hv[1])))
    q = 0.5 * (m[1] - L / np.tanh(0.5 * hv[0]/a))
    c = -a * np.sinh((p1[0]-p)/a)

    return a, p, q, c

The values fed to nr_first_guess() are somewhat arbitrary, but we need to keep in mind that we should not start too close to 0 as f(a) diverges there. And basically, we’re done! We can compute catenary parameters like so:

p1 = np.array([0.5, 0.6])
p2 = np.array([4.1, 2.5])
min_len = np.linalg.norm(p2-p1)
L = 2 * min_len
a, p, q, c = get_params(p1, p2, L)

Note that we calculate the minimal length as the Euclidean distance between P1 and P2, so we can make sure the input distance L will be greater than that. To get points on the catenary using the initial parametrization we would do:

xp = np.linspace(p1[0], p2[0], 100)
yp = catenary(xp, a, p, q)

And to use the arc length parametrization we would do:

s_bar = np.linspace(0, 1, 100)
xp_arclen = arclen_remap(s_bar, L, a, p, c)
yp_arclen = catenary(xp_arclen, a, p, q)

So let’s plot a few catenary curves with varying lengths, and compare the two parametrizations:

In blue, orange and green: three catenary curves of varying lengths, attached to the same anchor points. On the left: the red crosses mark 15 positions on each curve, obtained by sampling the curve at regular intervals of the argument. On the right: the red crosses are obtained by sampling the curve using an arc length parametrization. The thick transparent gray segments connect neighboring sampling points, and help visualize how a skinned catenary might look like in a computer graphics application.

You can see how the “default” parametrization leads to uneven segment lengths, the effect is more pronounced for longer curves. The segments obtained using the arc length parametrization on the other hand, are of equal lengths. If you were to skin a curve like this in a game, the arc length parametrization would completly get rid of texture stretching artifacts. Do note however that the segments tend to deviate more from the curve, so a higher number of sampling points must be chosen in order to retain a rounded shape. Have a look at the full source code (link below) if you want more detail on how to plot this.

In the next article, we are going to implement an arc length parametrization for cubic Hermite splines. The problem will be solved entirely numerically as no closed-form expression exists for these curves. Hope you enjoyed, see you on the next one!




The comment section requires the Utterances cookie in order to work properly. If you want to see people's comments or post a comment yourself, please enable the Utterances cookie here.