The dev probably meant that they don’t use TweenService, not that they don’t use interpolation at all. They could still calculate a point on a curve every frame and set the CFrame of the ball based on that. In the video posted by @fasmandom , TweenService:GetValue() was used for calculating a time number for calculating the point on the curve. TweenService is not needed for that, and it’s probably useless if the developers want to have precise control of the speed of the ball over time.
I don’t know whether the developers of Blade Ball calculate points on a parametric curve, and if they do, what kind of curve it is. However, after seeing this post I thought it’d be interesting to try making something similar (a ball following a character) using quadratic bezier curves (which are parametric curves).
Calculating curve control points
The function that creates the ball object (which is not an instance but can contain references to instances used for visualization) has parameters for the initial position and movement direction of the ball, and for the initial position of the target. It calculates an initial curve using these.
When using parametric curves for following a moving target, it is necessary to update the curve when the target moves. Thus, every frame, my code computes a new curve and then moves the ball along the new curve. P0 (start point) of the new curve is the position calculated for the ball on last frame. It’s tangent (derivative) direction at P0 is the same as the tangent direction of the last frame’s curve at the t value (curve parameter value between 0 and 1) of the ball position calculated last frame. P2 of the new curve is the new target position. P1 of the new curve is calculated by picking a point on the tangent line of P0 such that the point is in the derivative direction from P0 (this ensures G1 continuity i.e. that there is no sudden change of tangent direction at the point where two consecutive curves meet) and the lengths of P1 - P0 and P2 - P1 have a spesific ratio calculated in an arbitrary way that I thought would give good results but wasn’t actually as good as I thought. Anyways, the way I calculate P1 ensures that the curve will stay the same until the target moves (without explicitly checking whether it moves).
I’m not entirely happy with the results I get with the way I calculate the control point P1 of the curve. The direction changes sometimes feel too fast and it’s also possible for the path to go inside the ground. P1 could be calculated in many different ways as long as it is in the correct direction from P0. Also, calculating just a single quadratic bezier curve is limiting. Forming the curve calculated on a spesific frame from two quadratic curves or using a cubic curve would give more freedom for improving the curve shape but it can be difficult to decide a good way to use this freedom (how should the distances and directions between control points be calculated since there are so many possible ways?).
Calculating the t value for the new position
Every frame, a new ball position is calculated. For this, it is necessary to find a t value that corresponds to a sensible point on the curve (a sensible arc length from the last position of the ball). The arc length that the part should move along the curve should depend on its desired velocity and the time between frames. So, after calculating how much the part should move (a scalar), we need to find the t value that corresponds to this amount of movement (arc length between t = 0 and this t value is the amount of movement).
It might feel logical to just calculate the t value as t = (amount of movement) / (curve length)
and plug this into the bezier curve formula. However, there’s a problem. Normally, when calculating points along a bezier curve with constant difference between consecutive t values (for example t0 = 0, t1 = 0.1, t2 = 0.2, t3 = 0.3 etc.), the points are denser near the middle of the curve (the arc length between consecutive points is not constant). So when moving something along the curve by linearly changing t, it will first move quickly, then slow down near the middle of the length of the curve, and finally speed up again when approaching the other end point of the curve.
Arc length parameterization
When we want to control the speed at which something moves along the curve, arc length parameterization is needed. We need a function that, given a t value that represents the arc length between p0 and the desired point, calculates the regular t value that corresponds to this arc length t. With such a function, if we wanted to calculate such a point P that arc length between P0 and P is a quarter (0.25) of the length of the curve, we can just give 0.25 to the aforementioned function and plug the returned t value to the bezier curve point formula to get P.
The way I went about implementing arc length parameterization was by using the instantaneous speed of change of the point on the curve with respect to t. This speed is the magnitude of the derivative vector. First, I calculate points on the curve normally without arc length parameterization with constant t differences (for example the aforementioned 0, 0.1, 0.2, 0.3 etc.). As mentioned before, the arc length between such points is not constant. For each pair of consecutive such points, I create a QuadraticBezierInterval object that stores these points, their t values and the euclidean (straight/shortest) distance between them. The sum of these euclidean distances is set as curve.length. The length calculated this way is always an underestimation because the actual arc length between two points is greater than the straight (shortest) distance between them but with enough points its close to the correct length. When calculating the regular t value corresponding to an arc length t value, I first calculate the approximate desired arc length between t = 0 and this regular t by multiplying curve.length with the given arc length t. After that I sum BezierInterval euclidean distances until I find the interval in which the desired t value is by checking when the sum exceeds the desired arc length. Then I calculate the aproximate arc length from interval.point0 to the desired point.
local approximateArcLengthFromIntervalPoint0 = approximateArcLengthBeforeT - sumOfLengthsOfWholeIntervalsBeforeT
I derived the way I calculate the regular t value from this arc length from the equation for travelled distance s in uniformly accelerating motion. s in this case is approximateArcLengthFromIntervalPoint0
. The scalar acceleration (rate of change of speed) is not actually constant in the case of a bezier curve but this still seems to work pretty well. Here’s the equation.
s = v0 * Δt + 1/2 * a * Δt^2
v0 in the equation is the speed at interval.t0 and Δt = interval.t1 - interval.t0. The average acceleration is (v - v0) / Δt
where v is the speed at interval.t1. By substituting this into the equation, we get the following:
s = v0 * Δt + 1/2 * (v - v0) / Δt * Δt^2
-- after simplifying:
s = 1/2 * (v0 + v) * Δt
Then we can easily solve Δt from the equation and calculate the approximate desired regular t value.
Δt = 2*s / (v0 + v)
-- this is the t value that the function returns
t = interval.t0 + Δt = interval.t0 + 2*s / (v0 + v)
Now, finding a point and a derivative vector with an arc length t value can be done in the following way.
local regularT = self:getNormalTFromArcLengthParameterizationT(arcLengthT)
local arcLengthParameterizationPoint = self:getPointNormally(regularT)
local arcLengthParameterizationDerivativeVector = self:getTangentVectorNormally(regularT)
The way I calculate the arc length that the ball should move is based on the same equation, but this time I’ll just use the equation s = 1/2 * (v0 + v) * Δt
instead of solving Δt from it, and in this case, Δt is time between position updates, v0 is the ball speed at the moment of the earlier position update and v is the ball speed at the moment of the new position update. After that, arc length t is calculated using the aforementioned formula t = (amount of movement) / (curve length)
.
local curveArclengthParamaterizationT = amountToMove / newCurve.length
Other
The instances are meant to be created on the clients. They are only used for visualization.
The movement should be smooth because the client calculates the ball position every frame instead of it being sent from the server which means there won’t be sudden teleporting. However, the paths may be more or less different (depending on internet connection) between clients which can cause the distance from ball to target along the curve to be different between clients. Perhaps the desync could be decreased by sending the distance from ball to the target along the server curve to the clients and having the clients adjust the speed of the ball based on how much closer or further that client’s ball is from the target than it should be. I’m not sure how exactly this speed adjusting should work, though.