Getting consistent normals from a 3D cubic bezier path
Asked Answered
W

1

9

I'm writing a BezierPath class that contains a list of BezierPoints. Each BezierPoint has a position, an inTangent and an outTangent:

enter image description here

BezierPath contains functions for getting linear positions and tangents from the path. My next step is to provide functionality for getting the normals from the path.

I'm aware that any given line in 3D is going to have an unlimited number of lines that are perpendicular, so there isn't going to be a set answer.

My aim is for the user to be able to specify normals (or a roll angle?) at each BezierPoint which I will interpolate between to get normals along the path. My problem is that I don't know how to choose a starting tangent (what should the default tangent be?).

My first attempt at getting the starting tangents is using the Unity3D method Quaternion.LookRotation:

Quaternion lookAt = Quaternion.LookRotation(tangent);
Vector3 normal = lookAt * Vector3.up;
Handles.DrawLine(position, position + normal * 10.0f);

This results in the following (green lines are tangents, blue are normals):

enter image description here

For the most part this seems like a good base result, but it looks like there are sudden changes in certain orientations:

enter image description here

So my question is: Is there a good way for getting consistent default normals for lines in 3D?

Thanks, Ves

Wonky answered 22/8, 2014 at 18:10 Comment(2)
I don't know about "in unity", not familiar with it at all, but I do know Bezier math like the back of my hand and can give you the functions you need to get the unit normal at any coordinate, if you like. It won't be a unity answer, but it would work. If that's fine, I'll write up an answerTical
This really isn't a Unity specific problem. The moderators here just seem to tag most of my questions as unity related, so I thought I would save them the trouble. I would absolutely love to hear your solution for getting the normals!Wonky
D
38

Getting the normal for a point on a Bezier curve is actually pretty straight forward, as normals are simply perpendicular to a function's tangent (oriented in the plane of the direction of travel for the curve), and the tangent function of a Bezier curve is actually just another Bezier curve, 1 order lower. Let's find the normal for a cubic Bezier curve. The regular function, with (a,b,c,d) being the curve coordinates in a single dimension:

function computeBezier (t, a, b, c, d) {
  return a * (1-t)³ + 3 * b * (1-t)² * t + 3 * c * (1-t) * t² + d * t³
}

Note that Bezier curves are symmetrical, the only difference between t vs 1-t is which end of the curve represents "the start". Using a * (1-t)³ means the curve starts at a. Using a * t³ would make it start at d instead.

So let's define a quick curve with the following coordinates:

a = (-100,100,0)
b = (200,-100,100)
c = (0,100,-500)
d = (-100,-100,100)

just a 3D curve

In order to get the normal for this function, we first need the derivative:

function computeBezierDerivative (t,a,b,c,d) {
  a = 3*(b−a)
  b = 3*(c-b)
  c = 3*(d-c)
  return a * (1-t)² + 2 * b * (1-t) * t + c * t²
}

Done. Computing the derivative is stupidly simple (a fantastic property of Bezier curves).

Now, in order to get the normal, we need to take the normalised tangent vector at some value t, and rotate it by a quarter turn. We can turn it in quite a few directions, so a further restriction is that we want to turn it only in the plane that is defined by the tangent vector, and the tangent vector "right next to it", an infinitesimally small interval apart.

The tangent vector for any Bezier curve is formed simply by taking however-many-dimensions you have, and evaluating them separately, so for a 3D curve:

             | computeBezierDerivative(t, x values) |    |x'|
Tangent(t) = | computeBezierDerivative(t, y values) | => |y'|
             | computeBezierDerivative(t, z values) |    |z'|

Again, quite simple to compute. To normalise this vector (or in fact any vector), we simply perform a vector division by its length:

                   |x'|
NormalTangent(t) = |y'| divided by sqrt(x'² + y'² + z'²)
                   |z'|

So let's draw those in green:

our curve with tangents computed at many points

The only trick is now to find the plane in which to rotate the tangent vector, to turn the tangent into a normal. We know we can use another t value arbitrarily close to the one we want, and turn that into a second tangent vector damn near on the same point, for finding the plane with arbitrary correctness, so we can do that:

Given an original point f(t1)=p we take a point f(t2)=q with t2=t1+e, where e is some small value like 0.001 -- this point q has a derivative q' = pointDerivative(t2), and in order to make things easier for us, we move that tangent vector a tiny bit by p-q so that the two vectors both "start" at p. Pretty simple.

However, this is equivalent to computing the first and second derivative at p, and then forming the second vector by adding those two together, as the second derivative gives us the change of the tangent at a point, so adding the second derivative vector to the first derivative vector gets us two vectors in the plane at p without having to find an adjacent point. This can be useful in curves where there are discontinuities in the derivative, i.e. curves with cusps.

We now have two vectors, departing at the same coordinate: our real tangent, and the "next" point's tangent, which is so close it might as well be the same point. Thankfully, due to how Bezier curves work, this second tangent is never the same, but slightly different, and "slightly different" is all we need: If we have two normalised vectors, starting at the same point but pointing in different directions, we can find the axis over which we need to rotate one to get the other simply by taking the cross product between them, and thus we can find the plane that they both go through.

Order matters: we compute c = tangent₂ × tangent₁, because if we compute c = tangent₁ × tangent₂ we'll be computing the rotation axis and resulting normals in the "wrong" direction. Correcting that is literally just a "take vector, multiply by -1" at the end, but why correct after the fact when we can get it right, here. Let's see those axes of rotation in blue:

our curve with the cross-product axis added

Now we have everything we need: in order to turn our normalised tangent vectors into normal vectors, all we have to do is rotate them about the axes we just found by a quarter turn. If we turn them one way, we get normals, if we turn them the other, we get backfacing normals.

For arbitrary rotation about an axis in 3D, that job is perhaps laborious, but not difficult, and the quarter turns are generally special in that they greatly simplify the maths: to rotate a point over our rotation axis c, the rotation matrix turns out to be:

    |     c₁²     c₁*c₂ - c₃  c₁*c₃ + c₂ |
R = | c₁*c₂ + c₃      c₂²     c₂*c₃ - c₁ |
    | c₁*c₃ - c₂  c₂*c₃ + c₁      c₃²    |

Where the 1, 2 and 3 subscripts are really just the x, y, and z components of our vector. So that's still easy, and all that's left is to matrix-rotate our normalised tangent:

n = R * Tangent "T"

Which is:

    | T₁ * R₁₁ + T₂ * R₁₂ + T₃ * R₁₃ |    |nx|
n = | T₁ * R₂₁ + T₂ * R₂₂ + T₃ * R₂₃ | => |ny|
    | T₁ * R₃₁ + T₂ * R₃₂ + T₃ * R₃₃ |    |nz|

And we have the normal vector(s) we need. Perfect!

Except we can do better: since we're not working with arbitrary angles but with right angles, there's a significant shortcut we can use. In the same way that the vector c was perpendicular to both tangents, our normal n is perpendicular to both c and the regular tangent, so we can use the cross product a second time to find the normal:

                    |nx|
n = c × tangent₁ => |ny|
                    |nz|

This will give us exactly the same vector, with less work.

Our curve with normals!

And if we want internal normals, it's the same vector, just multiply by -1:

Our curve with inward normals

Pretty easy once you know the tricks! And finally, because code is always useful this gist is the Processing program I used to make sure I was telling the truth.

What if the normals behave really weird?

For example, what if we're using a 3D curve but it's planar (e.g. all z coordinates at 0)? Things suddenly do horrible things. For instance, let's look at a curve with coordinates (0,0,0), (-38,260,0), (-25,541,0), and (-15,821,0):

enter image description here

Similarly, particularly curvy curves may yield rather twisting normals. Looking at a curve with coordinates (0,0,0), (-38,260,200), (-25,541,-200), and (-15,821,600):

enter image description here

In this case, we want normals that rotate and twist as little as possible, which can be found using a Rotation Minimising Frame algorithm, such as explained in section 4 or "Computation of Rotation Minimizing Frames" (Wenping Wang, Bert Jüttler, Dayue Zheng, and Yang Liu, 2008).

Implementing their 9 line algorithm takes a little more work in a normal programming language, such as Java/Processing:

ArrayList<VectorFrame> getRMF(int steps) {
  ArrayList<VectorFrame> frames = new ArrayList<VectorFrame>();
  double c1, c2, step = 1.0/steps, t0, t1;
  PointVector v1, v2, riL, tiL, riN, siN;
  VectorFrame x0, x1;

  // Start off with the standard tangent/axis/normal frame
  // associated with the curve just prior the Bezier interval.
  t0 = -step;
  frames.add(getFrenetFrame(t0));

  // start constructing RM frames
  for (; t0 < 1.0; t0 += step) {
    // start with the previous, known frame
    x0 = frames.get(frames.size() - 1);

    // get the next frame: we're going to throw away its axis and normal
    t1 = t0 + step;
    x1 = getFrenetFrame(t1);

    // First we reflect x0's tangent and axis onto x1, through
    // the plane of reflection at the point midway x0--x1
    v1 = x1.o.minus(x0.o);
    c1 = v1.dot(v1);
    riL = x0.r.minus(v1.scale( 2/c1 * v1.dot(x0.r) ));
    tiL = x0.t.minus(v1.scale( 2/c1 * v1.dot(x0.t) ));

    // Then we reflection a second time, over a plane at x1
    // so that the frame tangent is aligned with the curve tangent:
    v2 = x1.t.minus(tiL);
    c2 = v2.dot(v2);
    riN = riL.minus(v2.scale( 2/c2 * v2.dot(riL) ));
    siN = x1.t.cross(riN);
    x1.n = siN;
    x1.r = riN;

    // we record that frame, and move on
    frames.add(x1);
  }

  // and before we return, we throw away the very first frame,
  // because it lies outside the Bezier interval.
  frames.remove(0);

  return frames;
}

Still, this works really well. With the note that the Frenet frame is the "standard" tangent/axis/normal frame:

VectorFrame getFrenetFrame(double t) {
  PointVector origin = get(t);
  PointVector tangent = derivative.get(t).normalise();
  PointVector normal = getNormal(t).normalise();
  return new VectorFrame(origin, tangent, normal);
}

For our planar curve, we now see perfectly behaved normals:

enter image description here

And in the non-planar curve, there is minimal rotation:

enter image description here

And finally, these normals can be uniformly reoriented by rotating all vectors around their associated tangent vectors.

Darmstadt answered 23/8, 2014 at 3:40 Comment(26)
Thanks a ton Mike, this all looks fantastic. Give me a few hours to let it sink in and reproduce it, and I'll be sure to mark this answer as correct.Wonky
Mike, this is working absolutely beautifully, but I'm noticing that I'm getting normals of 0,0,0 for straight lines. Is this the same in processing?Wonky
if you have a straight line, we strictly speaking have only a single tangent along the entire path, so there is no way to figure out which plane we're moving through. The easiest thing here, if it's part of a polyline, is to determine the normal of the curve before/after it, and use linear interpolation to get the "normals" along the line. They'll be lies (because in 3D line segments have no normal vector, only a normal plane) but they'll be white lies because they'll totally get the job done.Tical
with a sidenote that it's linear interpolation in terms of the normal angle around the segment, not the normal vector "point", because then it'd become a straight line instead of a nice helix =PTical
@Mike'Pomax'Kamermans Thank you for that very thorough answer --extremely helpful and just what I was looking for! The gist with the actual code is no longer to be found at the link you provide at the bottom, is there anyway for you to maybe upload it again?Mendacity
unfortunately I deleted it since (in the assumption the gist would stay up).Tical
Your post was extremely helpful to me all except your method of computing the normal doesn't really work right if you have a bezier segment composed of different segments. As soon as it goes over the break from one segment to next the transition is not smooth. Have you ever dealt with that situation before?Postnasal
Yes. It means your poly-Bezier is not C1-continuous: the end point of segment 1 and start point of segment 2 might overlap, and the direction of the tangents might look the same, but the actual tangent vectors coming in and going out of the point where the segments join up are not equal, and so anything involving the derivative will have a discontinuity from segment to segment. If you need smooth tangent/normal behaviour, you'll have to fix your poly-Bezier such that it exhibits C1 continuity.Tical
Hi. Really helpful post. It took me a while to grasp it but I understand it now. I use this to generate 3D cylinders around the curve, the normals can be used for lighting but also for generating the mesh itself. However, with the sign-switch in the normals in some of the curves, there is an interpolated twist in the mesh. Do you know how I could solve that? Make the signs consistent?Collie
why would there be sign switches for curves that fit around cylinders? The tangent plane will always be aligned with the cylinder surface?Tical
Look at your 4th and 5th screenshot. The (purple / red) normal flips over at some point. I have the same thing here. All my normals are constantly the same sign but at some point the sign just flips over and from there on stays the same. I printed them out and the numbers show it.Collie
What? They most certainly don't flip, the normals all point outward with respect to the tangent plane. At no point does a normal become an antinormal or vice versa - you can see the normals "going down" around the midpoint of the curve and that's because this is not a curve that cleanly circumscribes a cylinder. You can "fit one inside" but that's very much not the same thing. If we picked a curve that properly fits a cylinder by making it nicely symmetric, the curve normals will be entirely well-behaved with the respect to the circumscribed cylinder's surface.Tical
Yes, I meanwhile understood that I was wrong, the curve is twisting on your screenshots, that's why they looked like flipping over which is what I am getting. Conclusion: I must have a bug in my calculations :)Collie
Ok, I really think I have recreated your program here. Can you try it with the following coordinates? I get a flip-over of the normal/binormal at around 90% of the curve: (0.0, 0.0, 0.0), (-0.38, 2.68, 0.00), (-0.25, 5.41, 0.00), (-0.15, 8.21, 0.00) (they were randomly generated) and if it works for you, can you think of any reason why it does this at my end?Collie
Before doing any analysis: you're computing 3D normals on a 2D curve, so that's a bit odd. Still, I'll have a look.Tical
So I noticed: the curve is 2D by accident... (the random generator was flawed). It only seems to happen when Z is constant indeed. But even then, I would think that this algorithm should work, no? Thanks for your time by the way...Collie
Yeah, I'm trying to see why the normals slowly flip the closer we get to end point when using the cross/cross approach (imgur.com/a/2scASjb). Might take a little longer still though, because of day jobs =)Tical
The reason this happens is because the "next derivative" can become retrograde (i.e. they lie in the same plane, but the next derivative, offset to the current point, would lie "behind" the current one, rotating or even flipping the normal). I'll have to think about a different way to get the "true" normal! Very glad you brought this to my attention.Tical
I have noticed this behavior on other places than near the end on other curves as well. Thanks that you will look into this! I hope you can find an easy fix for this. It's a little beyond my mathematical understanding I'm afraid.Collie
A fix was found in math.stackexchange.com/questions/2843307/…, I will add an edit to this answer that explains how to use RM frames when regular normals spaz out.Tical
@Mike'Pomax'Kamermans very helpful, thank you very much for the detailed write up. The derivative has a mistake which leads to correct normals but incorrect tangents, there should not be a 3 in last term in the return statement.Vagabond
I don't get the 3d normal function. Why do you need an array of data if i just want the normal at a given t position? Also why do you start with t0 = -steps not not t0 = 0 ? Your basically looping outside of the range of the curve of 0 to 1 by going from -step to 1.0 ? That does not make much sense. Lastly if the function returns an array of frames which one is the correct one at any given point?Yonatan
Pretty sure the answer covers this: because "native" mathematical normal is not a good looking normal, and so is borderline useless if you're doing things like offsetting for camera tracking, path planning, model extrusion, and a million other tasks that rely on decent normals. Also, the bezier function is a segment of an infinite curve, so you start before zero so that the double reflection yields a normal at zero that is well-enough behaved. As for the last question: literally any vector off of a point at a right angle with the curve is a normal, so we don't care about "correct".Tical
What we're generally aiming for is a collection of normals along the curve that trace a smooth path. The normals that you get from rotating about the tangent vector by a quarter turn very much don't yield that, so while it's a good first exploration, it's a bad real world set of normals.Tical
I am facing same issue if could you help me - I am hopeless stuck in for 1 week math.stackexchange.com/questions/4379458/… - I need My Plane to be 90 degree always to the curve - user can Drag the plane - I have new point + next point based on that I calculate angle and apply eulerAnglesCloison
Uh... the answer to your question is literally this entire answer post?Tical

© 2022 - 2024 — McMap. All rights reserved.