Hello!
EDIT: I found that this post ended up being a continuation of this other post I had that was never answered (the root cause was the same) and so I updated the name of this post to be pretty much the same; luckily this time, I have more information, so hopefully it can be answered:
Context
I have code running every frame that’s supposed to calculate the ideal up vector of a rectangular prism every frame, but it does not work as expected. Notice how in this video, the prism just trips over little objects when it should instead try to climb over them as if there was a ramp:
I need an algorithm to come up with the “ideal up vector” depending on the surface region
Take the following two-dimensional examples:
Example 1
Here, the ideal up vector is just <0,1> since the entire surface is level and the surface normal all throughout the surface is <0,1>
Example 2
Here, the ideal up vector is <0,1> because, although part of the surface is not level, the endpoints of the surface are level and have the same up vector of <0,1> and are higher than the middle of the surface, making it negligible.
Example 3
Here, the ideal up vector is rotated clockwise even though the surface normal all throughout is essentially the same (except for the sharp “cliff”). This is because the surface is not level and so in order to touch the majority of the surface, the ideal up vector has to be rotated clockwise. Think of how a book would lean on two small steps (which resemble the above surface). The book would not be horizontal; it would rotated clockwise and be touching both the top part and bottom part.
Example 4
Here, the ideal up vector is <0,1> even though none of the surface normals are completely vertical (the one on the bottom is a mistake; the normal there would be undefined). The reason it ends up being undefined is because all the normals are symmetrical. The average of them would end up being <0, 1> (completely vertical).
Example 5
Here, the ideal up vector is rotated clockwise. This is because the of the very high ray intersection point which almost makes the other surface normals almost negligible. It makes this situation similar to the third example where if you placed a book on this surface, it would essentially look the same as if you placed it on the two steps.
Summary
So in summary, I noticed the following relationships and continuities with each example:
- The ideal up vector is not only dependent on the surface normals, but also the height of the intersection points associated with those normals in relation to each other. Take examples 3 and 5 where even though the normals were the same or variant, the height of one of the points really affected the situation, making the ideal up vector rotate to simulate reality where I made the comparison of a book leaning on those surfaces.
- The ideal up vector appears to be some sort of average; maybe the average of a bunch of “processed normals,” where each processed normal is every given normal rotated somehow, depending on its associated height with respect to the height of the other points.
- The ideal up vector seems to be highly influenced by the trend of the points—the path the points take if you were to graph them and connect them. This is why the height of a given point affects the rotation.
How can I expand these ideas to three dimensions?
Code
Helper function to rotate a vector a certain angle by an axis
local function rotateVectorByQuaternion(vector: Vector3, axis: Vector3, angle: number): Vector3
local halfAngle = angle * 0.5
local sinHalf = math.sin(halfAngle)
local vecX = vector.X
local vecY = vector.Y
local vecZ = vector.Z
local quatW = math.cos(halfAngle)
local quatX = axis.X * sinHalf
local quatY = axis.Y * sinHalf
local quatZ = axis.Z * sinHalf
return Vector3.new(
vecX * (1 - 2 * (quatY * quatY + quatZ * quatZ)) + vecY * (2 * (quatX * quatY - quatZ * quatW)) + vecZ * (2 * (quatX * quatZ + quatY * quatW)),
vecX * (2 * (quatX * quatY + quatZ * quatW)) + vecY * (1 - 2 * (quatX * quatX + quatZ * quatZ)) + vecZ * (2 * (quatY * quatZ - quatX * quatW)),
vecX * (2 * (quatX * quatZ - quatY * quatW)) + vecY * (2 * (quatY * quatZ + quatX * quatW)) + vecZ * (1 - 2 * (quatX * quatX + quatY * quatY))
)
end
Helper function to calculate a rotation matrix to rotate a certain angle around an axis
local function calculateRotationMatrix(axis: Vector3, angle: number): RotationMatrix
local cosA = math.cos(angle)
local sinA = math.sin(angle)
local oneMinusCosA = 1 - cosA
return {
row1 = Vector3.new(
cosA + axis.X * axis.X * oneMinusCosA,
axis.X * axis.Y * oneMinusCosA - axis.Z * sinA,
axis.X * axis.Z * oneMinusCosA + axis.Y * sinA
),
row2 = Vector3.new(
axis.Y * axis.X * oneMinusCosA + axis.Z * sinA,
cosA + axis.Y * axis.Y * oneMinusCosA,
axis.Y * axis.Z * oneMinusCosA - axis.X * sinA
),
row3 = Vector3.new(
axis.Z * axis.X * oneMinusCosA - axis.Y * sinA,
axis.Z * axis.Y * oneMinusCosA + axis.X * sinA,
cosA + axis.Z * axis.Z * oneMinusCosA
)
}
end
Function called every frame to update up vector to surface normal
local function updateGroundState()
local centroid = scooterPhysicsBox.Position
local currentUpVector = scooterPhysicsBox.CFrame.UpVector
local currentRightVector = scooterPhysicsBox.CFrame.RightVector
local currentLookVector = scooterPhysicsBox.CFrame.LookVector
local numSamplePoints = 100
local sampleRadius = maxDimension
local raycastResults: {RaycastResult} = {}
local lookRightCross = currentLookVector:Cross(currentRightVector)
local rotationMatrix = calculateRotationMatrix(normalizedVector3(lookRightCross),
math.atan2(currentLookVector.Y, math.sqrt(currentLookVector.X * currentLookVector.X + currentLookVector.Z * currentLookVector.Z)))
-- collect surface points and normals using raycasts
for i = 0, numSamplePoints - 1, 1 do
local angle = 2 * math.pi * i / numSamplePoints
local samplePoint = Vector3.new(
centroid.X + sampleRadius * math.cos(angle),
centroid.Y + size.Y * 0.5,
centroid.Z + sampleRadius * math.sin(angle)
)
samplePoint = Vector3.new(
rotationMatrix.row1:Dot(samplePoint),
rotationMatrix.row2:Dot(samplePoint),
rotationMatrix.row3:Dot(samplePoint)
)
local result = workspace:Raycast(samplePoint, -currentUpVector * size.Y, raycastParams)
if (result ~= nil) then
table.insert(raycastResults, result)
end
end
if next(raycastResults) == nil then
surfaceNormal = Vector3.yAxis
return
end
-- calculate processed normals and average them
local sumProcessedNormal = Vector3.new(0, 0, 0)
for _, result in ipairs(raycastResults) do
local heightDifference = result.Position.Y - centroid.Y
local processedNormal = rotateVectorByQuaternion(result.Normal, currentRightVector, heightDifference)
sumProcessedNormal += processedNormal
end
local inverseNumResults = 1 / #raycastResults
surfaceNormal = sumProcessedNormal * inverseNumResults
end