Rotate CFrame UpVector, loosely preserve LookVector (Change of gravity / Wall running)

I am reviewing @EmilyBendsSpace’s code here.

It does seem to do the exact thing that I am trying to achieve, I might mark it as the solution if it ends up working.

(Some comments added by me to express thought.)

local part = game.Workspace:WaitForChild("Block")
local dummy = game.Workspace:WaitForChild("Dummy")
local oldUp = part.CFrame.UpVector

local EPSILON = 1e-6 -- Tolerance value?

function GetAxisAngleToRotateVectorAtoB( a, b )
	
	if a.Magnitude < EPSILON or b.Magnitude < EPSILON then
		return Vector3.yAxis, 0
	end

	local ua = a.Unit
	local ub = b.Unit

	local dp = ua:Dot(ub.Unit) -- I wonder what we're using Dot product for.
	local cp = ua:Cross(ub) -- Cross between Unit A and B, likely the axis we want to rotate on?

	if math.abs(dp) > (1.0-EPSILON) or cp.Magnitude < EPSILON then -- Dot product used as some tolerance value?
		
		if dp < 0 then
			return Vector3.yAxis, math.pi -- Pi is used here?
		end
		
		return Vector3.yAxis, 0
	end

	local axis = cp.Unit
	local angle = math.atan2( cp.Magnitude, dp ) -- What does arc tangent do with cross and dot?
	
	return axis, angle
end


part:GetPropertyChangedSignal("CFrame"):Connect(function()
	
	local newUp = part.CFrame.UpVector
	
	local axis, angle = GetAxisAngleToRotateVectorAtoB(oldUp, newUp)
	
	local rotCF = CFrame.fromAxisAngle(axis,angle)
	
	local dummyCF = dummy:GetPrimaryPartCFrame()
	
	-- Why subtract position if we add position back after??
	dummyCF = rotCF * (dummyCF - dummyCF.Position) + dummyCF.Position
	
	dummy:SetPrimaryPartCFrame(dummyCF)
	
	oldUp = newUp
end)

I understand most of this code, the one part that kiiiinda confuses me a bit are these?

if a.Magnitude < EPSILON or b.Magnitude < EPSILON then
	return Vector3.yAxis, 0
end

My guess is that this is some sort of check to basically prevent “division by 0” sorta scenarios but for rotation?
Or it’s to rotate a character 180 degrees onto a ceiling?

I just see Magnitude < Epsilon and the first thing coming to mind is that if a value is super tiny or super large we might want to do something different with it.

if math.abs(dp) > (1.0-EPSILON) or cp.Magnitude < EPSILON then
		
	if dp < 0 then
		return Vector3.yAxis, math.pi
	end
	
	return Vector3.yAxis, 0
end

Here I see Pi being returned as the angle of the function.
Why Pi specifically?

local angle = math.atan2( cp.Magnitude, dp )

Arc tangent! I honestly forgot what it exactly does, I’ve only used a tangent function before when writing a neural network activation function to remap a input value (that was hyperbolic tangent).

I haven’t touched trigonometry in ages, haven’t fully learned it.

If the point of Devforum is learning then I am very curious about these specific choices made in code.

The premuliplication by rotCF should only affect the orientation of the dummy, not rotate his world space location about the world origin, so it’s applied only to the rotation part of the CFrame. The unmodified position of the part is then restored by adding it back. The alternative way to do this would be to conjugate the rotation change into dummy local space, i.e. replace that line of code with:

dummyCF = dummyCF * (dummyCF-dummyCF.p):Inverse() * rotCF * (dummyCF-dummyCF.p)

Either way, it’s basically subtracting and re-adding the dummy’s world space position, effectively moving the dummy to the origin, re-orienting him, then moving back to where he was.

This check is to avoid working with tiny vectors. In practice, the code I have that uses this helper function is passing in unit vector directions, so this is really just a sanity check specific to my use of this code. I just copy-pasted this function from some actual game code because it was convenient to illustrate the point; it’s doing more checks than you probably need/want in your use.

This bit is just saying the the two vectors are pointing in the same exact direction (or exactly opposite directions), within some tiny tolerance to account for what could just be floating-point difference in how they were calculated, treat them as the same vector and return zero rotation (or perfect 180, respectively). The axis being yAxis is arbitrary but valid when rotation amount is zero, because axis doesn’t matter when there’s no rotation at all.

The code above is actually invalid (incorrect) if the rotation is 180 deg (which is math.pi in Radians), because in that case not every axis is valid, only axes in the plane perpendicular to the vectors a and b, but in the code I copy-pasted this function from, angle of 180 is actually handled as a special case, externally to this function. I have another helper function GetArbitraryUnitPerpendicular( v ) that computes a valid axis of rotation before a CFrame is constructed. So in the sample place I posted above, if you were somehow able to rotate the cube exactly 180 degrees in a single frame, the dummy orientation would end up wrong, because I didn’t include the code that handles that. But that would take some skills!

1 Like

I accidentally ended up solving the problem after finishing this code here. :sob:

Tbh this is perhaps the simple / naive way of doing it now that I think of it.
No complex math functions are used here, it’s all silly CFrame operations chained together.

-- Rotates a CFrame while trying to preserve LookVector as much as possible.

function module.step_rot_up(
	from        : CFrame,  -- Object CFrame
	topos       : Vector3, -- Target floor / gravity direction.

	rotstepsize : number

)  : CFrame


	-- New normal.
	local newup = -CFrame.lookAt(from.Position, topos).LookVector



	-- Rotate side-ways 90 degrees.
	local newcf : CFrame = from * CFrame.Angles(0,math.rad(90),0)

	-- Now CFrame rotates to target on X axis only, then rotate 90 degrees back.
	newcf = CFrame.lookAlong(newcf.Position, newcf.LookVector, newup) * CFrame.Angles(0,math.rad(-90),0)

	
	-- Rotate CFrame towards target again, but now on Z axis.
	newcf = CFrame.lookAlong(newcf.Position, newcf.LookVector, newup)
	

	-- step() is an optional function that just interpolates CFrames linearly.
	return step(from, newcf, 0, rotstepsize) -- (Old, New, Position step, Rotation step)

end

It does something VERY similar to what your code does, except it only uses CFrame instructions in a very silly but non-verbose way I think.

I am still going to compare whether this or your method achieves the best results but also test performance because if I can get player gravity working I might want the same thing for NPCs / AI characters as well.

I can currently not record videos as that doesn’t work on my laptop for some reason but oh me goodness it works so well!

To @EgoMoose @EmilyBendsSpace and everyone else here I am very grateful for all the help I have gotten.

When this gravity engine is functional and production-ready I hope I can eventually release it to the public and open-source it along with some libraries I am working on with (hopefully) optimized and clean code.

I remember some other people on Roblox have either created games or written modules for enabling gravity changes in games but unfortunately some of these libraries and projects are outdated and/or unmaintained.

I aim to create something that is easy to maintain and won’t break due time.

My next step is actually implementing gravity/momentum with raycasts and potentially figure out if I can let a player stand/walk on a moving object but for that I’ll just make a new post if I run into problems.

1 Like
local epsilon : number = 1e-6 -- Tolerance value (e-6 adds 0 moves the 1 6 places behind the decimal point).

local module = {}




function module.get_axis_for_rotation( a : Vector3, b : vector3 ) : Vector3 & number

	local unit_a : Vector3 = a.Unit -- Probably already is a Unit vector.
	local unit_b : Vector3 = b.Unit

	local dot    : number  = unit_a:Dot  (a) -- Tolerance check?
	local cross  : Vector3 = unit_a:Cross(b) -- Axis we want.


	-- Check if vector isn't 0 or 180.

	if math.abs(dot) > (1.0 - epsilon)
		or cross.Magnitude < epsilon
		or dot < 0
	then
		if dot < 0 then return Vector3.yAxis, math.pi end
		
		return Vector3.yAxis, 0
	end


	-- Use CFrame.fromAxisAngle(axis, angle) to get a usable CFrame.

	return
		cross.Unit, math.atan2( cross.Magnitude, dot )
end




return module

I have reformatted/cleaned up the other function a bit and put it into it’s own module along with some annotations as reminders for why something is there.

Could probably be cleaned up further if I just were to return an Axis Angles right away instead of returning 2 results.

You’ll eventually come up against a specific limitation of some of these helper functions, along with the CFrame.new( fromPos, toPos ) constructor (which is effectively position + lookAt). The limitation is that the arguments to the functions don’t fully specify a unique CFrame, they give you one possible solution from infinitely many. In a normal, upright world, Roblox’s choice of internally taking cross products with the world Y axis usually gives the desired result. But when you go off the rails and upside-down, you may find that you need to use the more general CFrame.fromMatrix(), with all of its arguments fully specified by you (i.e. you provide your own “up” vector)

1 Like

I figured I would eventually run into something like that (I haven’t yet though).

So far the method I’ve used works fine, it even works for 180 degree rotations which I didn’t even write a sanity check for yet which is rather funny.

I’ve placed the dummy in various different positions and places and found no oddities yet, I’m honestly not even sure what sort of oddities to expect.

There’s probably a reason all the math-nerds define custom functions instead of doing everything with helper functions.
For my specific usecase of simply rotating a object or character while loosely preserving LookVector it seems to do exactly what I wanted it to.

I do kind of entertain the idea of making a custom humanoid / character physics engine that has intentional (or unintentional but still cool) bugs that a player could abuse in speedruns or to perform neat tricks.

I don’t think I have to worry about players having unfair advantages since I mostly focus on non-competitive / single-player type games with perhaps social features just to make it less lonely.

Is there a specific limitation you ran into before though? I’m curious about hearing that.