I am currently making an fps where the bullet comes out of the gun not the camera but since its off center I want the gun to point at the center of the screen at all times, is there a way to do that in Godot?
First of all, notice that in 3D the center of the screen does not correspond to a point, but to a line. A line going from the camera to the forward direction of the camera. We will refer to this line as the line of vision.
Every point on the line of vision map to the center of the camera when projected. However, every point on the line of vision results in a different orientation for the gun. So you need to pick a point.
And you can - of course - figure out the direction from the tip of the gun to that point.
I recommend using a Position3D
to represent the point from where the bullet come out from on the gun (which would also be where you would spawn the projectiles if it is not a hit-scan weapon). So you would do something like this:
var tip_position := $Weapon/TipPosition.global_transform.origin
var target_direction := tip_position.direction_to(selected_point)
Notice I'm assuming TipPosition
is an Spatial
child of Weapon
on the scene tree.
Alright, but what selected_point
are we going to use?
The simplest solution would be to pick a point on the line of vision that is at a large fixed distance. The issue with this approach is that it would appear to hit off target.
So, it is probably a better solution to use a Raycast
to find out where the line of vision intercepts with geometry and use that point.
Assuming you have a Raycast
node you can use the method get_collision_point
to find out where it collided. Yet, be aware that it might not collide with anything (e.g. the player is aiming at the sky), in that situation you can fallback to the first solution. You can check if it is colliding with something using the is_colliding
method. I remind you to make sure the Raycast
is enabled and pointing in the correct direction.
You could have no gun rotation. However, presumably that is not desirable.
The first idea is to use look_at
, like this:
$Weapon.look_at(selected_point, Vector3.UP)
The issue with is that the select point can change from one frame to the next, which might result in jerky motion. A simple workaround for that is only do that when the player fires the weapon.
However, if you want to smooth the motion we need something more complex.
We are going to make axis-angle rotations in such a way that a direction goes to another direction. So we have a current_direction
and a target target_direction
(which we figured out on the first part of this answer).
The current_direction
is - presumably - the negative z axis of the weapon coordinate system. That is:
var current_direction := -$Weapon.global_transform.basis.z
If we know the desired rotation axis we can use that. However, we initially don't, so let us use the cross product:
var current_direction := -$Weapon.global_transform.basis.z
var look_axis := current_direction.cross(target_direction)
look_axis = look_axis.normalized()
Be aware that you could end up with a ZERO vector. That happens when the directions are the same, and no rotation is necessary. So check for that:
var current_direction := -$Weapon.global_transform.basis.z
var look_axis := current_direction.cross(target_direction)
if not look_axis.is_equal_approx(Vector3.ZERO):
look_axis = look_axis.normalized()
# …
Since we figured out the axis via cross product, we don't need to ensure the directions are perpendicular to the axis. However, for future reference, I'll show you would do that for a rotation:
var current_direction := -$Weapon.global_transform.basis.z
var look_axis := current_direction.cross(target_direction)
if not look_axis.is_equal_approx(Vector3.ZERO):
look_axis = look_axis.normalized()
current_direction = current_direction.normalized().slide(look_axis)
target_direction = target_direction.normalized().slide(look_axis)
# …
And we - of course - need the rotation angle:
var current_direction := -$Weapon.global_transform.basis.z
var look_axis := current_direction.cross(target_direction)
if not look_axis.is_equal_approx(Vector3.ZERO):
look_axis = look_axis.normalized()
current_direction = current_direction.normalized().slide(look_axis)
target_direction = target_direction.normalized().slide(look_axis)
var look_angle := current_direction.signed_angle_to(target_direction, look_axis)
# rotate
With a rotation axis and angle we can rotate the gun, for example:
var transform := $Weapon.global_transform
transform = Transform(transform.basis * Basis(axis, angle), transform.origin)
$Weapon.global_transform = transform
Or simply:
$Weapon.rotate(axis, angle)
However, we want to smooth the rotation, remember? So we are not going to use the angle we figured out directly. Instead we are going to compute what angle it should rotate on the current frame with a rotation_speed
. Let us start with this:
var angle_delta := rotation_speed * delta # angle
Now, notice that our angle_delta
is always positive. But we computed a signed angle. So we are going to decompose it into sign and magnitud:
float look_angle_magnitud = abs(look_angle)
float look_angle_sign = sign(look_angle)
So we can limit how much we rotate so we don't overshoot, while preserving the sign:
look_angle = min(angle_delta, look_angle_magnitud) * look_angle_sign
The above code has the behavior of a sudden start/stop.
In a more conventional situation we would use an acceleration… However, I'll show you an stateless solution by making the velocity a function of the angle. This approach requires four parameters:
min_angle
and max_angle
.min_rotation_speed
and max_rotation_speed
.For practical proposes, the min_angle
and the min_rotation_speed
will be zero. Yet, I'll leave the variables in for reference in case you need them.
We need to compute the size of the angle range:
var angle_range := max_angle - min_angle
I'm assuming that max_angle > min_angle
, if that is not the case you can swap them. We should have a positive angle_range
.
We will only do easing if this angle_range
is not zero:
if angle_range > 0.0:
# …
Now, we will map our angle to a value between zero and one which indicates where it is in the angle_range
:
if angle_range > 0.0:
var x := min(
max(look_angle_magnitud - min_angle, 0.0) / angle_range,
1.0
)
# …
This should give you zero below min_angle
and one above max_angle
, a number in between in the middle.
And now that we have a value between zero and one, we can interpolate. I'll take advantage of Godot's ease
function:
if angle_range > 0.0:
angle_delta = angle_delta * ease(
min(
max(look_angle_magnitud - min_angle, 0.0) / angle_range,
1.0
),
speed_ease
)
And here I have introduced an speed_ease
variable which will control the acceleration curve. See this cheat sheet for reference.
Alright, we have smooth rotation. But we have another problem. The rotation steadily accumulates a roll. So the weapon may end up tip over instead of upright.
To solve that we are going to do a second rotation, by the same means as above. Except now the axis is the current_direction
. And we are taking the direction $Weapon.global_transform.basis.y
towards Vector3.UP
. And since we are specifying an axis that might not be perpendicular to those directions, we actually need the code I left in for future reference before. So it looks like this:
var target_roll_direction := Vector3.UP
var current_roll_direction := $Weapon.global_transform.basis.y
var roll_axis := current_direction
if not roll_axis.is_equal_approx(Vector3.ZERO):
roll_axis = roll_axis.normalized()
current_roll_direction = current_roll_direction.normalized().slide(roll_axis)
target_roll_direction = target_roll_direction.normalized().slide(roll_axis)
var look_angle := current_roll_direction.signed_angle_to(target_roll_direction, roll_axis)
# rotate
Yes, that is basically the same as before but with other variables. So feel free to encapsulate that in a method and call it twice with the appropriate parameters.