Welcome to 3D World

This text is a written version of the video above, with future plans to incorporate graphics and figures for illustration.
As I'm working my way through the backlog of videos that were published before this website, I suggest reading the article alongside the video for a comprehensive experience.

This video is in the Code Review series, where I show you actual systems I develop for an actual real life game project. And today's topic is all about perspective, depth and collisions.

What we need

Our game has isometric 2D graphics. The player can walk around in an environment where perspective looks 3D, even though everything is actually flat. So we have to fake depth somehow, starting with the visuals. Moving the character around and placing them behind or in front of objects is actually trivial. The depth of an object in the scene is proportional to its position on the Y axis, so we can just set the Order in Layer of every sprite to be a function of its Y coordinate.

public class DepthSortByY : MonoBehaviour
{
    // ...

    private void Start()
    {
        Sort();
    }

    private void Update()
    {
        Sort();
    }

    private void Sort()
    {
        renderer.SortingOrder = -(int)(transform.position.y * 10);
    }
}

You can learn more about how this is achieved by clicking here.

For the player character and other dynamic objects we just update the order at every frame and we're good!

There's a little bit of additional math involved for floating objects, but this is really not what this video is about. What I want to talk about today is physics. The game needs to detect multiple interactions between objects such as projectiles. We want to delete projectiles when they hit a wall, and we also need to detect whether they are behind or in front of objects.

Using 2D colliders is out of question because they don't care about depth or sorting order as long as the objects overlap. To put it simply, the collision is triggered if two "pixels" overlap regardless of depth. And I can't use 3D colliders eithers because, well, everything is flat.

Everything is flat because the perspective is drawn into the background image, conflating the vertical axis and the depth axis.

When you go up along the Y axis, there is no way to tell if we're flying upward vertically, or if we're walking on the ground toward the back of the scene. This is also where you realize shadows are paramount to the anchoring of objects in their environment. Having shadows in your game, even if they are fake ellipses placed below your characters, greatly improves readability. Everything is flat, the information is baked into a texture we can't realistically access, so how exactly are we going to achieve our fake 3D environment?

Welcome to 3D World

I'll cut to the chase, I did use 3D colliders. But since I couldn't place them in our flat, 2D environment, I made some sort of... parallel world where 3D is a thing. Dynamic objects, or objects that interact with dynamic objects, have a child object called "3D Body". These objects are prepared 3D colliders with a rigid body, and a script called, surprise surprise, ThreeDBody.

At runtime, every frame the ThreeDBody will update its position to match the position of the 2D object, except in 3D space.

The X position stays the same, but remember when I told you the isometric perspective conflated the Y and Z coordinates, well it's time to un-conflate them, by setting the 3D Y coordinate to 0 because objects are on the ground, the 3D Z coordinate is simply the 2D Y multiplied by a perspective factor.

private void UpdatePosition()
{
    Vector3 position = Target.position;
    position. z = position.y * 1.732f; // Magic number. I didn't verify this but is seems 2*sin(120°) does the trick.
    position.y = 0;
    position += Offset; // floating object's vertical offset
    transform.position = position;
}

On top of that, for objects that are above ground I added an Offset property where I manually track the altitude of objects. All objects are placed on the ground, but their collider is offset to an altitude that is tracked by the system. This is useful for projectiles, because I can both know where the impact is, and also where the ground is in order to add a shadow to the object.

View

3D World is on a different plane of existence, it doesn't show up on camera obviously, but I needed a way to view what's happening there for debug purposes, and also when designing more complex colliders than primitives.

The way I set it up, ThreeDBody objects do have renderers but they're placed on the ThreeDWorld layer, which the game camera does not see. I added a second camera that exclusively films this layer, and has basic controls to follow a target, rotate around it, zoom in, zoom out, etc. This camera outputs to the second display, so I can have a second game view or monitor and watch in real time both the game and the 3D parallel world, to check if the thickness of colliders is right, or why an arrow gets deleted before hitting the wall, this sort of things. It's really fun to have two games running in parallel with different views, and in a release build we simply remove the 3D world camera and everything works fine.

Coexistence

Now how do the 2D game and 3D World coexist as both have colliders? The 2D world does have colliders, this is because they are hand drawn as we wanted to make it extremely easy to integrate our complex backgrounds, we just draw the colliders on a texture as a separate layer, and we have accurate colliders for our character to run into.

So both worlds have colliders, and even if Unity 2D Physics and 3D Physics could interact, which they can't, we would get an overly constrained environment with contradictory info from both physics systems. The solution was to make the 2D game the main physics system.

3D World is set to be passive. As I said, position of 3D bodies is manually updated from the position of the 2D objects, but also "colliders" for 3D bodies are in fact triggers, meaning everything in 3D world is a ghost and will allow objects to collide and overlap.

3D World is simply here to send collision information to the game. For instance, when an arrow hits a wall, the two 3D triggers collide, and the ThreeDBody script will start tracking the collision, and send a message saying there was a collision and who is involved, to whomever subscribed to this event. If nobody is subscribed, the system goes on.

In the case of our arrow though, we have an ArrowController script that listens to the OnEnter event, and if the proper conditions are met, will delete the arrow from existence.

In short this 3D World really runs parallel to the 2D game, reads from it to update itself, then feeds back info on 3D collisions, which the 2D game might choose to listen to and act upon.

Conclusion

But anyway, that's 3D World and how it works. Sometimes I wonder if I should just flip this and make 3D World the main physics system, and make 2D World simply a view, a renderer that uses 2D to reflect the state of the game, but as it now stands, the comfort of being able to set up colliders for the character so easily by drawing them is enough to make me keep the current setup, as moving the physics to 3D would certainly mean we have to somehow model colliders for the very organic shapes of the environment, which we certainly don't have time for.

Have a good one everybody!

Music Credits