So I wanted to make this post to share a few niche, but useful CFrame tricks along with examples.
Be forewarned I’m going to start talking about Quaternions so if you’re not versed in that you’ll probably want to read up on that.
Rotation between two vectors CFrame
The first thing I want to talk about is calculating a CFrame that represents the shortest rotational path between two vectors. So given two unit vectors u
and v
can we calculate a CFrame that gives us the shortest rotational path between the two?
In other words a CFrame that would fulfill the following:
getRotationBetween(u, v) * u == v
Well first let’s see how we can deal with this problem without quaternions. There are three cases we have to watch out for:
-
When the
u
andv
vectors are the same i.e.u == v
, then we know the shortest rotational path is no rotation at all! -
If the
u
andv
are complete opposites i.e.u == -v
, then we choose an arbitrary axis and rotate by 180 degrees. -
When
u
andv
are different, but not complete opposites we use the cross product to get the axis of rotation and the dot product to get the angle of rotation. We can then plug these into the CFrame.fromAxisAngle constructor to get the rotation between the two vectors as a CFrame.
Putting this into code:
local function getRotationBetween(u, v, axis)
local dot = u:Dot(v)
if (dot > 0.99999) then
-- situation 1
return CFrame.new()
elseif (dot < -0.99999) then
-- situation 2
return CFrame.fromAxisAngle(axis, math.pi)
end
-- situation 3
return CFrame.fromAxisAngle(u:Cross(v), math.acos(dot))
end
Now this is fine and all, but there’s a more elegant solution (in my opinion at least) with quaternions.
We know that we could convert an axis angle rotation to a quaternion by doing the following:
theta = math.acos(u:Dot(v))
axis = u:Cross(v).Unit
qw = math.cos(theta / 2)
qx = math.sin(theta / 2) * axis.x
qy = math.sin(theta / 2) * axis.y
qz = math.sin(theta / 2) * axis.z
We also know that due to the magnitudes of the cross and dot product that:
u:Dot(v) = |u|*|v|*cos(theta) = cos(theta)
u:Cross(v).x = |u|*|v|*sin(theta) * axis.x = sin(theta) * axis.x
u:Cross(v).y = |u|*|v|*sin(theta) * axis.y = sin(theta) * axis.y
u:Cross(v).z = |u|*|v|*sin(theta) * axis.z = sin(theta) * axis.z
So you’ll note then that if I just went ahead and plugged the dot and cross products into the quaternion constructor I’d get a rotation that represents double the rotation along the correct axis! So the question becomes, is there a way to half this rotation amount? Yes, there are a couple.
A lazy way would be to (s)lerp half-way:
local function getRotationBetween(u, v, axis)
local dot, uxv = u:Dot(v), u:Cross(v)
return CFrame.new():Lerp(CFrame.new(0, 0, 0, uxv.x, uxv.y, uxv.z, dot), 0.5)
end
A more efficient way would be to recognize that since we’re dealing with a unit quaternion which is a point on a 4D hypersphere we can just add our quaternion to the un-rotated state and normalize. This might be somewhat hard to visualize in 4D so let’s use an image of a 3D sphere to hopefully get the message across.
In this case q_r
is our double rotation quaternion and q_u
is an unrotated quaternion. If we add them together we get quaternion w
.
This isn’t a unit quaternion however so we have to normalize it to w_n
.
So taking into account then that our double rotated quaternion can be represented as q_r = [u:Dot(v), u:Cross(v)]
and the unrotated quaternion as q_u = [1, Vector3.new(0, 0, 0)]
then adding them together we get: q_r + q_u = [1 + u:Dot(v), u:Cross(v)]
. We can just plug this in directly to the CFrame quaternion constructor as it will normalize the result for us.
Now one thing to note about an edge case that we weren’t able to catch with the lerp method. Say that u == -v
. In that case our added quaternion is: q_r + q_u = [0, Vector3.new(0, 0, 0)]
. Of course this can’t be normalized so we don’t have a valid rotation when there’s a 180 degree difference. As a result we’ll need to again bring over a backup axis.
local function getRotationBetween(u, v, axis)
local dot, uxv = u:Dot(v), u:Cross(v)
if (dot < -0.99999) then return CFrame.fromAxisAngle(axis, math.pi) end
return CFrame.new(0, 0, 0, uxv.x, uxv.y, uxv.z, 1 + dot)
end
Well there we have it, a function to get the CFrame rotation between two vectors. So what’s it useful for?
Arguably one of the more straight forward uses is that it provides a really quick and robust way to slerp unit vectors.
local rotation = getRotationBetween(u, v, axis)
for i = 0, 1.01, 0.01 do
local v = CFrame.new():Lerp(rotation, i) * u
end
Another neat use for this I actually discovered while talking to @Quenty. This function comes in handy when you’re trying to find a surface CFrame aligned with the surface edges.
The idea is as follows:
-
Pick a static surface normal that you’ll be able to use for any part.
-
Find the rotation between that universal surface normal and the arbitrary one you supply. Take note that we can also pick a static axis vector to rotate around given the 180 degree case since we know the value of the universal surface normal.
-
Multiply this rotation against part’s CFrame.
-
Rotate this by some constant (if you wish) such that the axes running parellel to the surface are to your preference.
So in my case I chose to use Vector3.new(0, 1, 0)
as the universal normal and as such I picked Vector3.new(0, 0, 1)
as the axis b/c I know it’s orthogonal to the universal normal.
-- makes it so the RightVector and UpVector run parellel to the surface and LookVector = surface normal
local EXTRASPIN = CFrame.fromEulerAnglesXYZ(math.pi/2, 0, 0)
local function getSurfaceCF(part, lnormal)
local transition = getRotationBetween(Vector3.new(0, 1, 0), lnormal, Vector3.new(0,0, 1))
return part.CFrame * transition * EXTRASPIN
end
Another example of this function is again using the slerp functionality to transition smoothly to a custom camera up vector. You can read about that here:
Swing-Twist decomposition
Now for a new question. Is it possible to find the spin around an arbitrary axis?
Say we have some rotation. We know that any rotation can be written as a combination of some arbitrary rotation and some amount of twisting. Commonly this is called a swing-twist decomposition.
rotation = swing * twist
So the question is can we some how decompose a CFrame to find the swing
and twist
values given we pick an arbitrary twist
axis?
Well there’s a couple approaches we can come at this from. We can use our function from above to find the amount we swing from our twist axis to the end rotation and then use the inverse to solve for the twist.
local function swingTwist(cf, direction)
local swing = CFrame.new()
local rDirection = cf:VectorToWorldSpace(direction)
if (rDirection:Dot(direction) > -0.99999) then
-- we don't need to provide a backup axis b/c it will nvr be used
swing = getRotationBetween(direction, rDirection, nil)
end
-- cf = swing * twist, thus...
local twist = swing:Inverse() * cf
return swing, twist
end
This is alright, but it’s dependent on the getRotationBetween
function we found earlier. That’s fine, but ideally we could have these functions be independent. Luckily we can figure out a more elegant solution by looking at the composition as quaternions.
We want to solve for qs
and qt
which when multiplied together give us our final rotation q
. We know the components of q
and we know the direction of the twist axis d
.
In summary:
q = [w, v]
qs = [ws, vs]
qt = [wt, vt]
q = qs * qt
d = unit twist axis
By definition we know a few results of using the dot product so let’s write them out for later use:
vs . vt = 0 -- the twist and swing axis are orthogonal by definition
vs . d = 0 -- d is the same direction as vt thus by same logic as above these are orthogonal
vt . d = |vt||d|*cos(0) = |vt| -- since d and vt go in same direction angle between them is 0
vs:Cross(vt) . d = 0 -- vt and d are in same direction thus the cross and d must be orthogonal
Now if we use the quaternion rules of multiplication and simplify what we can with the above:
q = qs * qt = [ws*wt - vt.vs, ws*vt + wt*vs + vs:Cross(vt)]
= [ws*wt, ws*vt + wt*vs + vs:Cross(vt)]
Thus,
w = ws*wt
v = ws*vt + wt*vs + vs:Cross(vt)
Now if we project q onto the unit twist axis we get a new quaternion which we’ll call qp
qp = q projected onto d
qp = [w, (v.d)*d]
= [w, (ws*(vt . d) + wt*(vs . d) + vs:Cross(vt) . d)*d]
= [w, ws*|vt|*d]
= [w, ws*vt]
= [ws*wt, ws*vt]
Now if we normalize qp
we’ll see:
qp / |qp| = [ws*wt, ws*vt] / sqrt(ws^2*wt^2 + ws^2*|vt|^2)
= ws*[wt, vt] / ws*sqrt(wt^2 + |vt|^2)
We know that sqrt(wt^2 + |vt|^2) = 1
because the twist quaternion is a unit quaternion by definition so therefore:
qp / |qp| = [wt, vt]
Now that we have qt
solving qs
is very easy we simply rearrange the original q = qs * qt
equation with inverses to solve for qs
q = qs * qt
q * qt:Inverse() = qs
As for code you might thing we have to normalize qp
but since the CFrame constructor does that for us already so we can just plug in the values unchanged.
local function swingTwist(cf, direction)
local axis, theta = cf:ToAxisAngle()
-- convert to quaternion
local w, v = math.cos(theta/2), math.sin(theta/2)*axis
-- plug qp into the constructor and it will be normalized automatically
local proj = v:Dot(direction)*direction
local twist = CFrame.new(0, 0, 0, proj.x, proj.y, proj.z, w)
-- cf = swing * twist, thus...
local swing = cf * twist:Inverse()
return swing, twist
end
Now, it’s important to note that by the nature of converting a quaternion to an axis angle, the rotation angle will be always be positive and it’s the axis that will flip.
This may not be ideal depending on what you’re trying to do, but it’s an easy fix. We just check the sign of the dot product of the input direction against the end rotation’s axis.
local function twistAngle(cf, direction)
local axis, theta = cf:ToAxisAngle()
local w, v = math.cos(theta/2), math.sin(theta/2)*axis
local proj = v:Dot(direction)*direction
local twist = CFrame.new(0, 0, 0, proj.x, proj.y, proj.z, w)
local nAxis, nTheta = twist:ToAxisAngle()
return math.sign(v:Dot(direction))*select(2, twist:ToAxisAngle())
end
Okay, so why is this useful?
Well, so far I’ve mostly found use in the twist aspect of the decomposition. It allows me to find out how much a CFrame has spun around an arbitrary axis. This is useful for things such as matching the camera’s rotation to a spinning part you’re standing on.
For instance, in the custom camera up place which I linked above we can add:
CameraModule.LastSpinPart = game.Workspace.Terrain
CameraModule.SpinPartPrevCF = CFrame.new()
CameraModule.SpinCFrame = CFrame.new()
CameraModule.SumDelta = 0
-- update this method elsewhere to get the part we're standing on for example
function CameraModule:GetSpinPart()
return game.Workspace.Terrain
end
function CameraModule:CalculateSpin()
local spinPart = self:GetSpinPart()
local rCF = spinPart.CFrame - spinPart.CFrame.p
local prCF = self.SpinPartPrevCF - self.SpinPartPrevCF.p
local direction = rCF:VectorToObjectSpace(self.UpVector)
-- get the angular difference between current and last rotation around the camera up axis
-- multiply by sign of y in case camera is upside down
local delta = twistAngle(prCF:Inverse() * rCF, direction) * math.sign(self.UpVector.y)
-- if we switch part we're standing on then we shouldn't rotate this frame
if (spinPart ~= self.LastSpinPart) then
delta = 0
end
self.SumDelta = self.SumDelta + delta
self.SpinCFrame = CFrame.Angles(0, self.SumDelta, 0)
self.SpinPartPrevCF = spinPart.CFrame
self.LastSpinPart = spinPart
end
function CameraModule:Update(dt)
if self.activeCameraController then
local newCameraCFrame, newCameraFocus = self.activeCameraController:Update(dt)
self.activeCameraController:ApplyVRTransform()
self:CalculateRotationCFrame() -- used for custom up vector
self:CalculateSpin()
local offset = newCameraFocus:Inverse() * newCameraCFrame
newCameraCFrame = newCameraFocus * self.SpinCFrame * self.RotationCFrame * offset
-- rest of stuff from custom up place...
As I mentioned above, if you update the :GetSpinPart()
method to what the character is standing on you get a result like so.
When most people attempt to do this type of thing they base it on the part’s delta CFrame as opposed to the delta angle. This can cause issues with things that wobble, but you’ll note if you test yourself that our solution solves that!
Another useful thing we can use this decomposition for is finding the center of rotation around an axis based purely off a delta CFrame.
CustomCamera.rbxl (143.2 KB)
Say we have a spinning platform like so:
We know the center of rotation is where the motor is, but finding it out with code is a whole different story.
Normally we are trying to solve for P
in this image given we know cfA
and cfB
:
Normally we can’t get very far because that’s not enough information to solve for P
or L
. However, since we can get the delta angle around an axis we actually have one more piece of info that makes solving this possible.
Now, with theta, solving is as simple as using the law of cosines and noting that the two angles at cfA and cfB must be the same.
local function findCenterOfRotation(cfA, cfB, axis)
local cf = cfB:Inverse() * cfA
local theta = twist(cf, axis)
local v = cfA.p - cfB.p
local vdot = v:Dot(v)
local alpha = (math.pi - theta)/2
local length = vdot > 0 and math.sqrt(0.5 * vdot / (1 - math.cos(theta))) or 0
-- rotate v around axis by alpha, normalize, and set new length as L
local point = vdot > 0 and cfB.p + (CFrame.fromAxisAngle(axis, alpha) * v).unit * length or cfA.p
return point
end
Applying this to our original spinning platform:
local lastSpinCF = spin.CFrame
game:GetService("RunService").Heartbeat:Connect(function(dt)
local point = findCenterOfRotation(spin.CFrame, lastSpinCF, AXIS)
center.CFrame = CFrame.new(point)
lastSpinCF = spin.CFrame
end)
center of rotation.rbxl (21.8 KB)
Well, those are two very specific, but advanced CFrame tricks. Hope they end up helping you out in some way as you dev.
Enjoy!