(Sorry for the weird title. As far as I know, there is no common name for this effect!)
Old FPS games like Doom did that thing where the sprites had multiple angles, and the angle shown was dependent on where the sprite was viewed from. Most of these games used 8 angles for the sprites, but I will show you how to write the code in a way that allows you to use any number of angles. I would provide a repo with code, but I'd rather explain how the code works so you can fit it into your game according to your needs.
First, we need to set a variable that stores the number of viewing angles our object will be visible from. I made this a constant, but if you want to make the number of angles different on a per-object basis, make it an export variable instead. I find that multiples of 8 work the best.
const ROTATION_ANGLES := 8
Now, we need to calculate the angle increment and store it as a variable, which is the number of degrees that the viewing angle needs to change by before the object renders from a different angle.
var angle_increment := TAU/float(ROTATION_ANGLES)
For the next part, I highly recommend getting a reference to the node that represents the object we want to do this effect on and storing it in a variable.
onready var object = $"Insert node path to your object here"
Next, in the _process()
function, we need to calculate the viewing angle relative to the camera's position and the object's forward axis. This is calculated with:
var theta = angle_wrap(atan2(target.x, target.z) - atan2(forward.x, forward.z))
target
is the normalized difference between the camera's position and the object's position. This can be calculated with ((camera.translation - object.translation) * Vector3(1, 0, 1)).normalized()
We multiply by Vector3(1, 0, 1)
because Retro FPS games did not take the Y-axis of the player into account.
forward
is the forward axis of the object we are viewing. You can get this from your object with object.get_global_transform().basis.z
. This is already normalized, so no need to normalize it yourself.
theta
will be in radians. angle_wrap()
is a function I made that adds tau if theta
is less than 0, and subtracts tau if theta
is greater than tau, to ensure that we have an angle between 0 and tau to keep things easy to manage.
Now that we have theta
, we need to figure out which "angle index" this value corresponds to. An index of 0 represents the angles at which the object should be shown facing the camera. From there, every angle increment going counter-clockwise increases the index by one, up until a maximum of the number of angles minus one. In the case having 8 viewing angles, this would mean that the index ranges from 0 to 7, and increases every 45 degrees.
The angle index can be calculated using the following code:
int((theta + (angle_increment * 0.5)) / angle_increment)
This will work regardless of whether theta
is in degrees or radians, but keep in mind what units theta
is in when using the value this returns.
Now that we have a way to get the angle index, we can use it to display our object from a certain angle! The way this is used is heavily dependent on how your game is set up, so here are some usage examples:
Use with Sprites
You can use Godot's Sprite3D node to place a 2D image in a 3D world. To get the same effect that many modern applications using this effect use, set the Sprite's Billboard property to "Y-Billboard".
If you use sprites, you'll need to have a version of each sprite facing every possible angle index. Early FPS games would often mirror the right-facing sprites to left, reducing the number of sprites that needed to be made.
Use with 3D Models
You can use the angle index to create a psuedo-sprite effect using a 3D model, allowing you to retain the flexibility offered by a model which a sprite does not. I did this by first setting up a scene arranged like this:
Enemy (Sprite3D)
Viewport
Mesh
Orbit (Spatial)
Camera
* RemoteTransform
The Sprite3D gets its texture from its child Viewport, which renders a model that cannot be seen by the player's camera. This can be accomplished either by placing the Mesh on its own visual layer, or by setting the Viewport's "Own World" property to true. Enabling Own World, however, will also cause the viewport to use its own lighting!
Orbit is a Spatial node located at Mesh's origin. The camera Orbit is parented to is offset along the positive Z axis. This allows the camera to show the model from a different angle when Orbit is rotated. To rotate the Camera so that it would display the model's angle index, I set Orbit's rotation like this:
orbit.rotation_degrees.y = angle_increment * get_angle_index(theta)
Lastly, the RemoteTransform has its "Remote Path" property set to the Mesh. This is necessary because the mesh being a child of the viewport causes it to not update when the Enemy node is moved or rotated. The RemoteTransform solves this issue.
The end result is that the 3D model will look like a prerendered sprite! You can make this look even more retro by lowering the resolution of the viewport, and by adding shaders to your model.
Edit 1: Fixed typos, added info about using a RemoteTransform for the 3D example.
Edit 2: More efficient code for getting the angle index