Maintaining a constant orientation about the viewing axis is unfortunately not something we can guarantee - the Hairy Ball Theorem strikes again - see this past Q&A for more on that.
For a camera, what we usually want is that our local "up" vector should remain continuous frame to frame (no big jumps / discontinuities), and when the spline is not too steep, we should gradually rotate to get that up vector as close to the world up direction as we can.
Here's a procedure we can use for that:
Vector3 viewDirection = spline.GetUnitTangentAt(progress);
Vector3 targetUp = previousUp + worldUp * untwistRate * deltaTime;
// Ensure localUp is perpendicular to viewDirection and of unit length
Vector3 localUp = Normalize(targetUp - Dot(viewDirection, targetUp) * viewDirection);
From these two basis vectors, we can construct a 3x3 rotation matrix, and either use that directly (if your code allows for it) or extract an angle-axis form from it.
The remaining steps depend on your coordinate system, which you haven't specified, so I'll use a common left-handed convention of {x+ = right, y+ = up, z+ = forward}
Our rotation matrix would then look like, this, where our localUp
and viewDirection
(forward) vectors form the second and third columns, and the first column is their cross product.
$$R = \begin{bmatrix} \left(\vec u \times \vec f \right) & \vec u & \vec f \end{bmatrix} = \begin{bmatrix}
u_y f_z - u_z f_y & u_x & f_x \\
u_z f_x - u_x f_z & u_y & f_y \\
u_x f_y - u_y f_x & u_z & f_z
\end{bmatrix}$$
As shown on Wikipedia here, we can compute the rotation angle using the trace of this matrix:
$$\begin{align}
\theta &= \cos^{-1} \frac {\text {tr}(R) - 1} 2\\
&= \cos^{-1} \frac {u_yf_z - u_zf_y + u_y + f_z - 1} 2\\
\end{align}$$
If that comes out to zero, the object is still in its default orientation, and you can pick a rotation axis arbitrarily (rotating 0° around any axis gives the same result).
If it comes out less than 180°, we can extract the axis \$\vec a\$ like so:
$$\vec a = \begin{bmatrix} R_{32} - R_{23} \\ R_{13} - R_{31} \\ R_{21} - R_{12} \end{bmatrix} = \begin{bmatrix}u_z - f_y \\ f_x - u_xf_y + u_yf_x \\ u_zf_x - u_xf_z - u_x\end{bmatrix}$$
For exactly 180° rotations, this will give us a zero vector, and for rotations close to that, we may lose numeric stability. As Wikipedia states:
In this case, it is necessary to diagonalize R and find the eigenvector corresponding to an eigenvalue of 1.
You can use that eigenvector as your rotation axis. I'm a little rusty on eigenvectors (I usually work with rotations as quaternions or matrices, rather than angle-axis form), so the last few steps for this case are left as an exercise for the reader. 😉