{"id":1168,"date":"2026-04-16T19:57:08","date_gmt":"2026-04-17T02:57:08","guid":{"rendered":"https:\/\/www.toadstorm.com\/blog\/?p=1168"},"modified":"2026-04-16T19:57:10","modified_gmt":"2026-04-17T02:57:10","slug":"revisiting-parallel-transport","status":"publish","type":"post","link":"https:\/\/www.toadstorm.com\/blog\/?p=1168","title":{"rendered":"Revisiting Parallel Transport"},"content":{"rendered":"\n<p>I was asked the other day about fixing a little issue with <a href=\"https:\/\/github.com\/toadstorm\/MOPS\/wiki\/Tools#mops-orient-curve\" data-type=\"URL\" data-id=\"https:\/\/github.com\/toadstorm\/MOPS\/wiki\/Tools#mops-orient-curve\" target=\"_blank\" rel=\"noreferrer noopener\">MOPs Orient Curve<\/a>, a node that really hasn&#8217;t changed much in MOPs since it was first launched way back in 2018. Most of the reason it hasn&#8217;t been revisited is because a few versions ago SideFX created their own curve orientation tool, the aptly-named Orientation Along Curve SOP, which more or less does what MOPs Orient Curve did but with more technical options. <\/p>\n\n\n\n<p>However, the user was having trouble getting Orientation Along Curve to do exactly what he wanted and I figured I&#8217;d take a second look at the MOPs tool as it was one that was originally written by Manu from Entagma, using the Parallel Transport method outlined in his <a href=\"https:\/\/www.sidefx.com\/tutorials\/parallel-transport\/\" data-type=\"link\" data-id=\"https:\/\/www.sidefx.com\/tutorials\/parallel-transport\/\" target=\"_blank\" rel=\"noreferrer noopener\">handy VEX tutorial<\/a>. I never had to get too involved in this part of the toolkit because Manu already handled all the math for me, so this was a perfectly good excuse for me to learn the algorithm and see if I could improve the functionality a bit. Hopefully this is also a good look at how this algorithm works for the reader who doesn&#8217;t want to watch an eight-year-old Vimeo tutorial.<\/p>\n\n\n\n<p>First, the source issue: on a closed circle, why do the end points at the 3 o&#8217;clock position here have <strong>slightly skewed<\/strong> orientations?<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/motionoperators.com\/blog\/wp-content\/uploads\/2026\/04\/image-1024x950.png\" alt=\"\" class=\"wp-image-92\"\/><figcaption class=\"wp-element-caption\">The instances at the 3 o&#8217;clock position have slightly skewed orientations. This position is where the first and last points on the circle primitive meet.<\/figcaption><\/figure>\n\n\n\n<p>The problem is grounded in the fact that the 3 o&#8217;clock position on this particular circle are where the first and last points on the circle primitive meet. Manu&#8217;s original implementation of the parallel transport algorithm, based on <a href=\"https:\/\/www.toadstorm.com\/stuff\/TR425.pdf\" data-type=\"link\" data-id=\"https:\/\/www.toadstorm.com\/stuff\/TR425.pdf\" target=\"_blank\" rel=\"noreferrer noopener\">this paper here<\/a>, starts on the first point of the curve and computes orientations from first point to last point. Each point is figuring out what its tangent vector is (what direction points to the next location on the curve) based on the position of the next point in line. If there&#8217;s no more points in line, though, the algorithm just extrapolates the next tangent based on what the previous one looked like. It wasn&#8217;t really meant for this edge case where the start and end points are linked!<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What&#8217;s parallel transport?<\/h2>\n\n\n\n<p>Let&#8217;s take a step back and talk about what this algorithm even does. If you read the linked white paper it looks like a bunch of insane wizardry because, like all good white papers, the math has to be done with proofs, and if you&#8217;re not used to reading those it looks like absolute hell. However the idea behind it and the implementation of it, when done on discrete sets of points like what you deal with in 3D graphics, is really not terribly complicated.<\/p>\n\n\n\n<p>The goal of parallel transport is to create a smoothly-transitioning orientation along a set of edges so that you don&#8217;t end up with sudden flips or discontinuities along the line. If you&#8217;ve ever tried to animate something along a path or copy objects to the points of paths with lots of twists and turns, you&#8217;ve likely seen this problem before. Here&#8217;s a loopy curve with an orientation created by the very naive Polyframe SOP that just guesses at orientations based on computed tangent and normal vectors:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/motionoperators.com\/blog\/wp-content\/uploads\/2026\/04\/image-2-1024x793.png\" alt=\"\" class=\"wp-image-94\"\/><figcaption class=\"wp-element-caption\">A curve with orientations created by the Polyframe SOP (visualized by MOPs Visualize Frame). Note the discontinuities in the circled areas.<\/figcaption><\/figure>\n\n\n\n<p>You can see in those circled areas that there are sudden flips in the green and red vectors. This happens in part because each point on the curve is more or less computing its local tangent (blue) and normal (green) vectors independently of any other location on the curve; they just look at their immediate neighbors and figure that&#8217;s good enough.<\/p>\n\n\n\n<p>Now the same curve orientations computed via parallel transport:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/motionoperators.com\/blog\/wp-content\/uploads\/2026\/04\/image-3-1024x763.png\" alt=\"\" class=\"wp-image-95\"\/><figcaption class=\"wp-element-caption\">The same curve, with orientations computed by parallel transport.<\/figcaption><\/figure>\n\n\n\n<p>The core idea here is simple: starting at the beginning of the curve, get the tangent to the curve and a normal vector. Then for each successive point along the curve, figure out the angle that would rotate the previous tangent vector to match the next tangent. Then rotate the normal vector by that same angle, around the <strong>binormal<\/strong> (the red vector in the above images). The binormal is a fancy way of saying a vector that&#8217;s orthogonal to the tangent and normal vectors, meaning it&#8217;s an axis perpendicular to both those other vectors.<\/p>\n\n\n\n<p>If you&#8217;ve ever read my <a href=\"https:\/\/www.toadstorm.com\/blog\/?p=942\" target=\"_blank\" rel=\"noreferrer noopener\">Even Longer-Winded Guide to Houdini Instancing<\/a> you might remember that in order to compute an orientation you need to have three vectors: a &#8220;forward&#8221; direction, an &#8220;up&#8221; direction, and a &#8220;side&#8221; direction. The &#8220;side&#8221; direction, however, can be automatically computed by determining the <strong>cross product<\/strong> of the other two vectors. Crossing two vectors gets you a third vector orthogonal to the other two. In parallel transport we&#8217;re doing the same thing to get the binormal vector that we use as the axis of rotation for our normal vectors.<\/p>\n\n\n\n<p>By incrementally applying these small rotations to the normal vectors along the length of the curve, the algorithm ensures that there&#8217;s no sudden flips or discontinuities in orientations while passing along the curve, regardless of how many twists and turns are present. Conceptually it&#8217;s not too bad! The implementation is, as always, the annoying part.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Computing Tangents<\/h2>\n\n\n\n<p>The first thing to do to make this algorithm work is to compute the tangents along the curve. In Manu&#8217;s original implementation for MOPs, the tangents were always computed by starting at point <code>i<\/code>, looking ahead to point <code>i+1<\/code>, subtracting their point positions from each other, and normalizing the result. This gets you a vector pointing to the next point along the curve. This was the original VEX code:<\/p>\n\n\n\n<blockquote class=\"wp-block-quote\">\n<pre class=\"wp-block-code\"><code>for (int i = 0; i&lt;pntcnt-1; i++)\n{\npush(tangents, normalize(point(geoself(), \"P\", pnts&#91;i+1]) - point(geoself(), \"P\", pnts&#91;i])));\n}<\/code><\/pre>\n<\/blockquote>\n\n\n\n<p>However, as we saw in the first illustration, this fails when you have curves that join at the ends. It also tends to run into trouble when you have curves with sharp corners; those abrupt changes in direction can mean anything you copy to those points or slide along them will have similarly sudden changes in orientation you don&#8217;t want. So what to do?<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" loading=\"lazy\" width=\"1024\" height=\"776\" src=\"https:\/\/www.toadstorm.com\/blog\/wp-content\/uploads\/2026\/04\/image-2-1024x776.png\" alt=\"\" class=\"wp-image-1172\" srcset=\"https:\/\/www.toadstorm.com\/blog\/wp-content\/uploads\/2026\/04\/image-2-1024x776.png 1024w, https:\/\/www.toadstorm.com\/blog\/wp-content\/uploads\/2026\/04\/image-2-300x227.png 300w, https:\/\/www.toadstorm.com\/blog\/wp-content\/uploads\/2026\/04\/image-2-768x582.png 768w, https:\/\/www.toadstorm.com\/blog\/wp-content\/uploads\/2026\/04\/image-2-1536x1163.png 1536w, https:\/\/www.toadstorm.com\/blog\/wp-content\/uploads\/2026\/04\/image-2.png 1554w\" sizes=\"(max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\">A curve with sharp angles. Note how the blue forward vectors always point to the next point along the curve; on sharp turns this can be very sudden! The circled red gnomon shows the joined start and end points, again computing the wrong angle because the last point doesn&#8217;t have anywhere to look forward to!<\/figcaption><\/figure>\n\n\n\n<p>Rather than using point numbers to determine the next point along the line, we can instead use <strong>vertex connectivity<\/strong>. We can see that the first and last points on the curve are joined together as part of a single polyline primitive, but points alone don&#8217;t have this connectivity information. Vertices do!<\/p>\n\n\n\n<p>One of the extremely cool things about Houdini is that so much of the various contexts use points and vertices to describe things beyond just geometry. Rigid body and Vellum constraints are two-vertex primitives connected together with a single polyline primitive. APEX graphs are just geometry, with points describing functions and parameters and vertex connectivity describing inputs and outputs to these functions, like a node graph. Most importantly for us, the <strong>KineFX rigging system also uses vertex connectivity<\/strong> to define parent\/child relationships between joints. This means we can lean on the KineFX helper functions in VEX to quickly and easily determine both the next and previous points of each point along the line, without getting in the weeds with <code>pointvertex()<\/code> or <code>pointhedge()<\/code> or anything else. It takes a certain kind of sick person to actively want to do anything with <a href=\"https:\/\/www.sidefx.com\/docs\/houdini\/vex\/halfedges.html\" target=\"_blank\" rel=\"noreferrer noopener\">half-edges<\/a> and I figure most of my readers here would rather find an easier route.<\/p>\n\n\n\n<p>The library in question we want is the KineFX helper library for rig hierarchies, called <code>kinefx_hierarchy.h<\/code>. It lives alongside a bunch of other KineFX-related libraries (totally worth checking out) in <code>$HFS\/packages\/kinefx\/vex\/include\/<\/code>. If you open it up in a text editor, you&#8217;ll see a couple of important functions here, namely <code>getparent()<\/code> and <code>getchildren()<\/code>. These two functions crawl along half-edges, using the connectivity of polyline vertices to determine the previous (parent) and next (child) points on the curve. So anyways let&#8217;s steal them! All you have to do is start your Primitive Wrangle (because we&#8217;re iterating over points in a primitive, in order of points) with this line:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>#include &lt;kinefx_hierarchy.h&gt;<\/code><\/pre>\n\n\n\n<p>You can do this with other built-in VEX libraries, too. It&#8217;s worth spending some time checking these out and studying them if you&#8217;re reasonably comfortable with VEX.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Forward Tangents<\/h3>\n\n\n\n<p>Now we want to compute the tangents for each point along the curve, using the <code>getchildren()<\/code> helper function. The first point in the array returned by <code>getchildren()<\/code> is the nearest connected downstream point. We&#8217;ll set this up in a Primitive Wrangle so that it operates in the order of points on the curve:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>#include &lt;kinefx_hierarchy.h>\nint pts&#91;] = primpoints(0, @primnum); \nvector tangents&#91;];\nvector normals&#91;];\nvector first_normal = point(0, \"N\", pts&#91;0]);\n\nfor(int i=0; i&lt;len(pts); i++) {\n    \/\/ FORWARD TANGENTS\n    int desc&#91;] = getchildren(0, pts&#91;i]);\n    int child = desc&#91;0];\n    if(len(desc) > 0) {\n        \/\/ tangent is child's position minus mine\n        vector P1 = point(0, \"P\", pts&#91;i]);\n        vector P2 = point(0, \"P\", child);\n        vector tangent = normalize(P2 - P1);\n        push(tangents, tangent);\n    } else {\n        \/\/ no children found, duplicate previous tangent\n        push(tangents, tangents&#91;-1]);\n    }\n    \/\/ add default normal to populate normals array\n    push(normals, first_normal);\n}<\/code><\/pre>\n\n\n\n<p>What we&#8217;re doing here is creating two empty arrays, one for tangents and one for normals (since we need both to compute the final orientations), and then populating those arrays one at a time with the tangents of the curve and with the <em>first<\/em> normal found on the curve. The first normal is our starting point for all other rotations; we&#8217;ll be overwriting all of the other normals later on. To get the tangents, we get the first point number returned by <code>getchildren()<\/code>, get the position <code>P<\/code> of that point, and subtract the position of the <em>current<\/em> point from that point. Normalizing the result gets us a vector that points straight down the curve to the next point in line. If the length of the array returned by <code>getchildren()<\/code> is zero, then the point has no children and we just use the last index of the tangents array and copy it (index <code>-1<\/code> is Python-like array syntax for &#8220;get the last index&#8221;).<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Backward Tangents<\/h3>\n\n\n\n<p>We could also go in the other direction&#8230; compute the tangents by looking <em>backwards<\/em> to the parent point. On some curves this might be preferable to looking forwards! The code is very similar, we&#8217;re just relying on <code>getparent()<\/code> rather than <code>getchildren()<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>for(int i=0; i&lt;len(pts); i++) {\n    \/\/ BACKWARD TANGENTS\n    int parent = getparent(0, pts&#91;i]);\n    \/\/ tangent is parent's position minus mine\n    if(parent > -1) {\n        vector P1 = point(0, \"P\", pts&#91;i]);\n        vector P2 = point(0, \"P\", parent);\n        vector tangent = normalize(P2 - P1);\n        push(tangents, tangent);\n        push(normals, first_normal);\n    } else {\n        \/\/ there's no parent, so we must be the first.\n        \/\/ look ahead to the next point, but reverse the result\n        \/\/ so we're still pointing in the same direction we expect.\n        vector P1 = point(0, \"P\", pts&#91;i]);\n        vector P2 = point(0, \"P\", pts&#91;i+1]);\n        vector tangent = normalize(P1 - P2);\n        push(tangents, tangent);\n        push(normals, first_normal);\n    }\n}<\/code><\/pre>\n\n\n\n<p>If you look at the definition of <code>getparent()<\/code>, it returns -1 if there&#8217;s no parent found. If a point has no parent, we can safely assume it&#8217;s the start of the curve, so we just look to the next point in line to get the tangent vector and then reverse it so it&#8217;s pointing in the same overall direction as the other tangents will be.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Averaging Tangents<\/h3>\n\n\n\n<p>A third option would be to get the average of both neighboring tangents. We can look ahead for the forward tangent, look backwards for the backward tangent, flip one of them, then average them both together to get a blended result. For curves with sharp corners like the above example, this can sometimes get us smoother results. Check it out:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code> for(int i=0; i&lt;len(pts); i++) {\n        \/\/ AVERAGE OF BOTH TANGENTS\n        int desc&#91;] = getchildren(0, pts&#91;i]);\n        int child = desc&#91;0];\n        int parent = getparent(0, pts&#91;i]);\n        \n        vector P1 = point(0, \"P\", pts&#91;i]);\n        vector P2 = point(0, \"P\", child);\n        vector P3 = point(0, \"P\", parent);\n        \n        \/\/ average between forward and (flipped) backward tangents\n        vector tangent1 = normalize(P2 - P1);\n        vector tangent2 = normalize(P3 - P1) * -1;\n        \n        \/\/ exceptions for start and end points\n        if(len(desc) == 0) {\n            tangent1 = tangents&#91;-1];\n        }\n        if(parent == -1) {\n            tangent2 = tangent1;\n        }\n\n        \/\/ blend forward and backward tangents\n        vector tangent = normalize(lerp(tangent1, tangent2, 0.5));\n        \n        push(tangents, tangent);\n        push(normals, first_normal);\n    }<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Tangents Comparison<\/h3>\n\n\n\n<p>We&#8217;re getting ahead of ourselves a bit here but it&#8217;s helpful to see a visual comparison of these different approaches to computing tangents:<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img decoding=\"async\" loading=\"lazy\" width=\"1000\" height=\"841\" src=\"https:\/\/www.toadstorm.com\/blog\/wp-content\/uploads\/2026\/04\/tangents_comparison.gif\" alt=\"\" class=\"wp-image-1173\"\/><figcaption class=\"wp-element-caption\">The tangent vectors are blue. Normals are green, binormals are red. Note the subtle difference between the &#8220;forward&#8221; method and the &#8220;average&#8221; method around sharp corners.<\/figcaption><\/figure>\n\n\n\n<p>As an aside, notice how the tangent of the point all the way on the far left (the one that was circled before) isn&#8217;t pointing out into space anymore? This is because of our new approach to getting tangents based on connectivity instead of point number! We still have to actually implement the algorithm, though&#8230;<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Computing Normals<\/h2>\n\n\n\n<p>Now that we have the tangents figured out and added to an array, it&#8217;s time to actually calculate the normals. If you look at page 9 of the white paper, you&#8217;ll see this extremely helpful (but somewhat confusing) pseudo-code for the algorithm:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" loading=\"lazy\" width=\"1024\" height=\"702\" src=\"https:\/\/www.toadstorm.com\/blog\/wp-content\/uploads\/2026\/04\/image-3-1024x702.png\" alt=\"\" class=\"wp-image-1174\" srcset=\"https:\/\/www.toadstorm.com\/blog\/wp-content\/uploads\/2026\/04\/image-3-1024x702.png 1024w, https:\/\/www.toadstorm.com\/blog\/wp-content\/uploads\/2026\/04\/image-3-300x206.png 300w, https:\/\/www.toadstorm.com\/blog\/wp-content\/uploads\/2026\/04\/image-3-768x527.png 768w, https:\/\/www.toadstorm.com\/blog\/wp-content\/uploads\/2026\/04\/image-3.png 1059w\" sizes=\"(max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\">Pseudo-code for the parallel transport algorithm.<\/figcaption><\/figure>\n\n\n\n<p>Translating this into something a little more human-readable:<\/p>\n\n\n\n<ul>\n<li>For each point of the curve (except the last one):\n<ul>\n<li>Compute the <strong>binormal <\/strong>by cross multiplying this point&#8217;s and the next point&#8217;s tangents<\/li>\n\n\n\n<li>If the binormal is 0, this means the tangents are parallel and we can set the next point&#8217;s normal <code>V<\/code> to be the same as the current point&#8217;s normal.<\/li>\n\n\n\n<li>If not, first normalize the binormal (divide it by its own length).<\/li>\n\n\n\n<li>Next, a little trigonometry: compute the angle that would rotate this point&#8217;s tangent to match the next point&#8217;s tangent. <\/li>\n\n\n\n<li>Rotate the next point&#8217;s normal vector around the binormal, by the computed angle.<\/li>\n\n\n\n<li>Store the next point&#8217;s updated normal vector and continue on.<\/li>\n<\/ul>\n<\/li>\n<\/ul>\n\n\n\n<p>An interesting quirk here that will come up later when we code this, and one of the reasons we&#8217;re doing everything via temporary arrays instead of binding things to attributes like we often would in wrangles, is that we&#8217;re setting the values of the <em>next<\/em> normal in line (index <code>i+1<\/code>) and not the current index!<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The trigonometry bit (sorry)<\/h3>\n\n\n\n<p>Those of you with a mediocre math education like mine (thanks America) are probably going to be a bit put off by that bit of <code>arccos<\/code> magic that&#8217;s happening in the algorithm. In short, you can find the angle between two unit vectors by computing the <strong>arc cosine of the dot product of the two vectors<\/strong>. Let&#8217;s take a step back and break down what that means.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">Dot Product<\/h4>\n\n\n\n<p>The dot product of two vectors can be used for a lot of things, but in short it&#8217;s a measurement of the similarity of two vectors. If the dot product of two normalized vectors (meaning the length is 1.0) is 1.0, they are parallel. If the dot product is 0.0, they are orthogonal (perpendicular). You can think of it like a kind of <strong>ratio<\/strong> between two vectors.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">Arc Cosine<\/h4>\n\n\n\n<p>If you remember SOHCAHTOA from trigonometry class, you might remember what the <strong>cosine<\/strong> of a triangle is. The cosine is a measurement of the <strong>ratio<\/strong> (there&#8217;s that word again) between the adjacent side of a right triangle and the hypotenuse side, relative to a given angle. It&#8217;s easier to see this as an illustration:<\/p>\n\n\n\n<figure class=\"wp-block-image size-medium\"><img decoding=\"async\" loading=\"lazy\" width=\"300\" height=\"258\" src=\"https:\/\/www.toadstorm.com\/blog\/wp-content\/uploads\/2026\/04\/Untitled_Artwork-1-300x258.jpg\" alt=\"\" class=\"wp-image-1176\" srcset=\"https:\/\/www.toadstorm.com\/blog\/wp-content\/uploads\/2026\/04\/Untitled_Artwork-1-300x258.jpg 300w, https:\/\/www.toadstorm.com\/blog\/wp-content\/uploads\/2026\/04\/Untitled_Artwork-1-768x661.jpg 768w, https:\/\/www.toadstorm.com\/blog\/wp-content\/uploads\/2026\/04\/Untitled_Artwork-1.jpg 976w\" sizes=\"(max-width: 300px) 100vw, 300px\" \/><\/figure>\n\n\n\n<p>The <strong>cosine<\/strong> of the angle \u019f here will be equal to the <strong>ratio<\/strong> of these two sides of the triangle, whatever they are.<\/p>\n\n\n\n<p>Now imagine that these <strong>two sides are your two tangent vectors<\/strong>, superimposed onto each other. You know that the <strong>cosine<\/strong> of the angle between these two vectors will be equal to the <strong>ratio<\/strong> between them. You now know that <strong>ratio<\/strong>, because it&#8217;s the dot product! Take a look at this identity:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>cos(\u019f) = dot(v1, v2)<\/code><\/pre>\n\n\n\n<p>The only question, then, is how to get that angle \u019f given the dot product you have. The answer is the <strong>inverse cosine<\/strong>, often called the <strong>arc cosine<\/strong>. This lets you get that angle based on the ratio you have:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>acos(dot(v1, v2)) = \u019f \n<\/code><\/pre>\n\n\n\n<p>Now you have the angle between the two tangents and you&#8217;re ready to transform the normal vector!<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Rotating the Normals<\/h2>\n\n\n\n<p>Now that we have all our tangents computed and we understand how to calculate the angle between one tangent vector and the next, we can finally use this information to rotate each normal vector by this same angle, one at a time along the curve points. For each point in the array (except the last one), we&#8217;ll compute the <strong>binormal<\/strong> between the current tangent and the next, compute the <strong>angle<\/strong> between the same, and then <strong>rotate the normal<\/strong> around the binormal by that angle. Compare this to the human-readable series of steps above:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>for(int i=0; i&lt;len(normals)-1; i++) {\n    \/\/ get a vector orthogonal to the current and next tangent. this is our\n    \/\/ axis of rotation for rotating the normal.\n    vector binormal = cross(tangents&#91;i], tangents&#91;i+1]);\n    \/\/ if the two tangents we're testing are parallel, crossing them will return\n    \/\/ a zero-length vector. we can skip the rest of this math and just assume the\n    \/\/ orientation hasn't changed.\n    if(length2(binormal) == 0) {\n        normals&#91;i+1] = normals&#91;i];\n    } else {\n        binormal = normalize(binormal);\n        \/\/ find the angle that rotates this tangent onto the next\n        float theta = acos(dot(tangents&#91;i], tangents&#91;i+1]));\n        \/\/ rotate the current normal around the binormal by this angle\n        matrix3 m = ident();\n        rotate(m, theta, binormal);\n        \/\/ rotate the next normal by this amount and set it\n        normals&#91;i+1] = m * normals&#91;i];\n    }\n}<\/code><\/pre>\n\n\n\n<p>The only thing we haven&#8217;t really talked about yet is that <code>matrix3<\/code> and <code>rotate()<\/code> function in there. Again going back to the <a href=\"https:\/\/www.toadstorm.com\/blog\/?p=942\" target=\"_blank\" rel=\"noreferrer noopener\">Even Longer-Winded Guide to Houdini Instancing<\/a>, if you want to transform a vector (meaning rotate, translate, or scale), you can multiply it by a <strong>matrix<\/strong>. In this particular case we only care about rotation, so instead of a full 4&#215;4 matrix we&#8217;re using a 3&#215;3 <code>matrix3<\/code> to handle the vector rotation. <\/p>\n\n\n\n<p>First we create an &#8220;identity&#8221; matrix, which is like a null matrix that doesn&#8217;t do anything. Then we rotate that matrix around the binormal by the angle that we calculated using our fancy arc cosine operation. Then we multiply our current normal by this matrix to rotate it, and apply the result to the <em>next<\/em> normal in the array (again, index <code>i+1<\/code>). <\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Binding the Result<\/h2>\n\n\n\n<p>These arrays are done but we still have to actually create Houdini attributes out of them. There are a lot of different ways you can store orientation data in Houdini, but my preference is to use the <code>orient<\/code> attribute, which is a <code>vector4<\/code> (four numbers) stored as a <em>quaternion<\/em>, a kind of math wizard shorthand for rotations that I can&#8217;t possibly explain here. Fortunately VEX will do this all for you. All you have to do is make a transform out of the tangent and normal vectors we have in our array, and then let VEX convert that to a quaternion:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ bind arrays to attributes\nfor(int i=0; i&lt;len(pts); i++) {\n    vector N = normals&#91;i];\n    vector T = tangents&#91;i];\n\n    \/\/ build orthonormal matrix\n    matrix3 m = maketransform(T, N);\n\n    \/\/ create quaternion and write to point attribute\n    vector4 orient = quaternion(m);\n    setpointattrib(0, \"orient\", pts&#91;i], orient, \"set\");\n}<\/code><\/pre>\n\n\n\n<p>The reason we have to use <code>setpointattrib()<\/code> instead of just setting <code>p@orient<\/code> is because we&#8217;re again in a Primitive Wrangle SOP; we&#8217;re not operating on points. This is technically a little bit slower than setting point attributes in a Point Wrangle SOP, but because we have to iterate over all the points in order instead of in parallel, we have no choice. The <code>maketransform()<\/code> function creates a transform matrix out of two vectors: a forward vector and an up vector. For the purposes of orienting things along a curve, I like to use the tangent of the curve as the forward direction, which is why it&#8217;s used as the first term of <code>maketransform()<\/code>. If you prefer objects facing the normals instead you could always swap the terms.<\/p>\n\n\n\n<p>Now with our orient attribute bound we can use MOPs Visualize Frame to see the resulting orientation! Here&#8217;s a comparison again of the default orientation created by a Polyframe SOP just dumbly guessing at tangent and normal vectors, and our implementation of Parallel Transport that&#8217;s smoothly rotating those orientations from start to end:<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img decoding=\"async\" loading=\"lazy\" width=\"1012\" height=\"1000\" src=\"https:\/\/www.toadstorm.com\/blog\/wp-content\/uploads\/2026\/04\/pt_comparison.gif\" alt=\"\" class=\"wp-image-1177\"\/><\/figure>\n\n\n\n<p>Take a close look again especially at the green vectors. Those are the normal vectors that we rotated into position using Parallel Transport. You can see with the default Polyframe orientation method, there are areas where they flip from one side of the curve to another, generally at points of inflection along the curve. Parallel transport makes all the normals nice and consistent along the entire length.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Extra Fixins<\/h2>\n\n\n\n<p>You can always add more stuff to this process to make things more useful or predictable. If the first curve normal is too similar to the first tangent, for example, you can&#8217;t meaningfully compute a binormal because the vectors are parallel. This often happens on straight vertical lines or circles. In cases like this it helps to have an optional normal override that the user can set before the tangent and normal arrays are filled:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>if(chi(\"override_first_N\")) {<br>    first_normal = normalize(chv(\"N_override\"));<br>}<\/code><\/pre>\n\n\n\n<p>In my new implementation for MOPs I&#8217;m also including a warning to let the user know if the vectors are too similar so they&#8217;re not confused by a bad result:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>if(dot(normals&#91;0], tangents&#91;0]) > 0.999999) {\n    setdetailattrib(0, \"__warning\", 1);\n}\n<\/code><\/pre>\n\n\n\n<p>The Error SOP, when used in an HDA, can output errors or warnings to the user when cooked. In this case I set the &#8220;Report This Error&#8221; parameter expression to <code>detail(0, \"__warning\", 0)==1<\/code> which evaluates to 1 if the above VEX code determined the vectors are too similar. In the Type Properties dialog of an HDA you have the option to designate <strong>Message Nodes<\/strong>; any nodes in this list will have their errors or warnings percolate upwards to the main network view. It&#8217;s extremely useful for providing useful feedback to users who might otherwise not know what&#8217;s going wrong!<\/p>\n\n\n\n<p>Additionally, you might want to roll, pitch, or yaw the orientations along the length of the curve. This can be fairly easily done by computing more rotations around each axis (roll = Z, yaw = Y, pitch = X) and multiplying the original orientation by these values. Going back again to the Long-Winded Guide, remember that if you want to <strong>rotate around a local axis<\/strong> to an existing orientation, you can just rotate a world axis (such as +Z, the roll axis) by the existing orientation to get the axis of rotation you want:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>vector twist_axis = qrotate(p@orient, {0,0,1});\nvector yaw_axis = qrotate(p@orient, {0,1,0});\nvector pitch_axis = qrotate(p@orient, {1,0,0});\n<\/code><\/pre>\n\n\n\n<p>Now you can create quaternions representing these additional rotations by providing an angle and an axis as the two parameters to <code>quaternion()<\/code>. In my example I&#8217;m using a <code>@__curveu<\/code> attribute calculated via a Resample SOP (or a UV Texture SOP set to Arc Length Spline) that gets me the relative position along the curve for each point in a 0-1 range. This neatly maps to ramp attributes so the end user can visually tweak the total amount of rotation along the length of the curve:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>float twist_amount = ch(\"twist_amount\") * chramp(\"twist_ramp\", @__curveu);\nfloat yaw_amount = ch(\"yaw_amount\") * chramp(\"yaw_ramp\", @__curveu);\nfloat pitch_amount = ch(\"pitch_amount\") * chramp(\"pitch_ramp\", @__curveu);\nvector4 twist = quaternion(radians(twist_amount), twist_axis);\nvector4 yaw = quaternion(radians(yaw_amount), yaw_axis);\nvector4 pitch = quaternion(radians(pitch_amount), pitch_axis);<\/code><\/pre>\n\n\n\n<p>Then it&#8217;s just a question of what order you decide to combine these rotations in to get the final result. For example here&#8217;s applying pitch, yaw, and then roll:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>p@orient = qmultiply(pitch, p@orient);\np@orient = qmultiply(yaw, p@orient);\np@orient = qmultiply(roll, p@orient);\n\n<\/code><\/pre>\n\n\n\n<p>Here&#8217;s the result of a 720-degree twist around the roll axis, modulated by the position along the curve:<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img decoding=\"async\" loading=\"lazy\" width=\"1000\" height=\"989\" src=\"https:\/\/www.toadstorm.com\/blog\/wp-content\/uploads\/2026\/04\/twist.gif\" alt=\"\" class=\"wp-image-1178\"\/><\/figure>\n\n\n\n<p>I&#8217;ll be including this newer implementation of MOPs Orient Curve in the next Experimental release. Again, this is mostly for educational purposes because SideFX&#8217;s Orientation Along Curve SOP already handles this just fine, but it&#8217;s never a bad thing to take a closer look at some of these algorithms we take for granted and see what makes them tick. Happy transporting!<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I was asked the other day about fixing a little issue with MOPs Orient Curve, a node that really hasn&#8217;t changed much in MOPs since it was first launched way back in 2018. Most of the reason it hasn&#8217;t been revisited is because a few versions ago SideFX created their [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":[],"categories":[30],"tags":[31,68],"_links":{"self":[{"href":"https:\/\/www.toadstorm.com\/blog\/index.php?rest_route=\/wp\/v2\/posts\/1168"}],"collection":[{"href":"https:\/\/www.toadstorm.com\/blog\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.toadstorm.com\/blog\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.toadstorm.com\/blog\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.toadstorm.com\/blog\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=1168"}],"version-history":[{"count":3,"href":"https:\/\/www.toadstorm.com\/blog\/index.php?rest_route=\/wp\/v2\/posts\/1168\/revisions"}],"predecessor-version":[{"id":1180,"href":"https:\/\/www.toadstorm.com\/blog\/index.php?rest_route=\/wp\/v2\/posts\/1168\/revisions\/1180"}],"wp:attachment":[{"href":"https:\/\/www.toadstorm.com\/blog\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=1168"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.toadstorm.com\/blog\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=1168"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.toadstorm.com\/blog\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=1168"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}