Making a 3D compass
9/11/2024
Martin Bozhilov
In this tutorial, we’ll show you how to create a responsive 3D compass for player navigation using the power of CSS 3D transforms. We’ll guide you through every step, ensuring you can enhance your UI with a dynamic and visually appealing 3D compass.
Previewing the sample
To see the sample live you can find the whole project in the ${Gameface package}/Samples/uiresources/UITutorials/Compass
directory.
Building the Base for the 3D Scene
To begin, we need to set up the scene that will act as a container for our 3D transformations. This container will provide the 3D space in which the compass will live. Most importantly, this is where we’ll define the perspective, giving our compass the depth and realism of a 3D environment.
In our CSS we’ll add:
And in the HTML we will add:
Key CSS properties
-
perspective
- This property is crucial because it creates the illusion of depth. The lower the value, the more extreme the 3D effect will be (objects will appear closer). A value of 2500px gives us a subtle but realistic 3D scene. -
perspective-origin
- This property defines the vanishing point for our 3D scene, simulating the effect of viewing the compass from a top-down perspective. The 0% moves the origin horizontally (no shift), while -300% pushes the perspective’s vertical origin far above the actual scene, mimicking a bird’s-eye view.
Creating the compass planes
HTML and CSS setup
After our scene setup is ready we can begin implementing the compass itself.
The key to achieving the 3D illusion of a circular shape is to create a polygon made up of multiple “planes” (or faces). The more planes we use, the smoother the circular effect will appear. For this example, we will use 24 planes, which gives us a 360-degree compass, where each plane represents 15 degrees (360 / 24 = 15°).
In our html we will define the compass
Next, we need to ensure that 3D transformations are properly applied to the compass by adding transform-style: preserve-3d
in the CSS.
For the planes themselves we need to ensure that they stack on top of one another with position: absolute
and that they take 100% width of their parent.
Initial JS setup
In this setup, we define:
PLANE_COUNT
: Set to 24 for smooth circular movement.THETA
: The angle between each plane, calculated as360 / PLANE_COUNT
.
Calculating the Angle and Radius
To correctly position each plane, we need to calculate two key values:
- The rotation angle (theta) for each plane.
- The radius, which determines how far each plane is “pushed out” along the Z-axis. Without this, the planes would overlap at the center.
Here’s how we calculate the radius, adapted from a formula found here::
planeWidth
is the width of the scene because the planes have 100% width of the scene.
Here, Math.tan(Math.PI / PLANE_COUNT)
computes the tangent of the angle between the planes, and dividing the width of the scene by this tangent gives us the distance (radius)
to space the planes out.
The Math.ceil
and + precision
help adjust for precision in rendering, ensuring that the planes don’t visually overlap due to rounding.
Creating and Positioning the Planes
Now that we have the radius, let’s create and position each plane:
rotateY
: Rotates each plane by a specific angle around the Y-axis. This spreads them out in a circular arrangement.translateZ
: Moves each plane outward along the Z-axis by the calculated radius, preventing them from stacking on top of each other.
The applyTransformation
function ensures that both the rotation and the outward translation are applied to each plane.
And with that we should have the base of our compass ready!
Adding Points of Interest and Navigation Markers
Time to populate our shell of a compass with markers and some points of interest.
Adding directions and navigation markers
First off lets define an array with the world’s directions
Next we will enhance our compass-plane
class.
In our case, each of the 24 planes covers 15 degrees (360 / 24 = 15°). Since we want 3 evenly spaced marks per plane, each mark will represent 5 degrees.
By using display: flex
and justify-content: space-around
, the marks are automatically spaced evenly within each plane, corresponding to these 5-degree intervals.
Now back to the javascript we will enhance our createCompassPlanes
function and add a new one for creating the marks.
The logic here is to add a cardinal direction on every 3rd plane and marks on every other.
Populating compass with points of interest
In order to be able to move our pois dynamically we will use
data-bind-for
with a model to mimic data from a game.
Here we represent each POI with an object. We add a property for name
, angle
, content
and transform
.
The transform is left empty upon initialization. On engine ready we will use the angle of the POI along with the already calculated radius to update
the transform property of each POI the same way we did with the faces of the compass.
Now let’s add some styles and connect everything in the html.
We make use of data-bind-class-toggle
to dynamically add the class for the enemy pois and data-bind-style-transform
to apply the transformation for each POI.
Hiding the backside of the compass
To achieve the clean, polished look of the compass shown in the article’s image, we need to ensure that the backside of each plane is not visible when the compass rotates. This is particularly important in 3D environments, where the reverse sides of the elements can be exposed.
We can hide the backside of all compass elements with just one CSS property:
This property ensures that the back side of each plane is hidden from view, so when the compass rotates, only the front-facing elements are visible
And with that our compass is ready to be used!
Mocking enemy movement
In most games, objects (like enemies) move constantly around the map, and the compass must update in real-time to reflect accurate information for the player. To simulate this, we’ll use an interval to randomly adjust the positions of our points of interest (POIs) to mimic enemy movement.
To make our POIs move, we simply update the transform
property in the POIs array within our model:
Here we create an interval of 1 second where we will just assign a random value to the POI’s angle and
update the transform property with the new angle and the already calculated radius.
After recalculating the transform for each POI, the updateModel
function is called to update the compassModel
and synchronize it with the Player.
Now our compass should look something like this
Keep in mind
In a real game environment, the engine itself would handle the movement and calculations of objects like enemies. You would need to:
- Perform all position and angle calculations within the game engine.
- Build a transformation string in the format:
rotateY(${angle}deg) translateZ(${radius}px)
. - Pass this transformation to the appropriate attribute (such as
data-bind-style-transform
) to reflect the changes in the compass.
Rotating the compass
To rotate the compass all you need to do is apply a transform: rotateY()
CSS style to the compass element.
In our demo we will do it with our compassModel
. First let’s add a property to store the rotation value.
Then we will mock a player rotation with mouse movement. In our JS let’s add
Here we are making the width of the screen represent 360 degrees rotation. Now when the mouse is at the leftmost part of the screen the rotation will be 0° and at the rightmost part, the rotation will be 360°, completing a full circle.
Once the rotation value is updated, we need to ensure it is reflected on the compass element. We’ll use data binding to achieve this:
Here we simply update the value like we did with the POIs movement.
Now you can move the compass using your mouse!
In a real game environment, you’ll need to calculate the player’s rotation based on actual input (such as the player’s movement or camera rotation)
and then pass that angle to the compass in the following format: rotateY(${rotateY}deg)
.
Bind this transformation to the data-bind-style-transform
attribute to ensure that the compass updates in sync with the player’s movements.
Making it responsive
When building a compass for various screen sizes, we need to ensure that the layout adapts to the viewport changes. As the viewport shrinks or expands, the distance between the planes (and the POIs) needs to adjust accordingly. If the planes are too close, they will overlap; if too far apart, there will be gaps between them.
To make the compass responsive, we start by adding an event listener that detects when the window is resized. This allows us to recalculate and reapply the radius and plane positions dynamically:
After adding the event listener for resize we will extend our updateCompassLayout
function to also update each plane and the POIs with the newly calculated radius.
And now if you try to resize your screen the compass should respond to the viewport changes.
Something to consider
While we have ensured that the compass responds to changes in the viewport size, there is a known issue with responsiveness: at certain viewport widths,
the value we get from the radius formula ((planeWidth / 2) / Math.tan(Math.PI / PLANE_COUNT))
isn’t perfectly accurate. This results in the planes either overlapping by 1 to 2 pixels
or being spaced too far apart, as shown below:
This is the reason we added a precision
variable with a value of 1 when calculating the radius, to make a small adjustment that ensures proper translation on full screen.
As of now there isn’t a way to deal with this issue. If you plan to use the compass in a real project, you’d have to manually adjust the translateZ
value for the
viewport widths you plan on using to ensure accurate spacing between the panels.
However, if you plan to go with a non-transparent compass design, the slight overlap will not be visible, you will just need to subtract 4 - 5 pixels from the radius to ensure they are never spaced out. In contrast, if the planes are opaque or semi-transparent (as in our demo), the overlap will be more noticeable, requiring finer adjustments to prevent visible gaps or overlaps.
Here’s a demonstration with background-color: black
In conclusion
In this tutorial, we’ve explored how to create a responsive 3D compass for player navigation using CSS 3D transforms and JavaScript. We covered everything from building the base of the compass and positioning planes, to adding points of interest and simulating dynamic player and enemy movement.
While building a fully functional compass in a real game environment would require more precise calculations and adjustments, especially for responsiveness across different viewports, this guide provides a solid foundation to create a visually engaging and interactive 3D compass UI.
Feel free to customize and expand upon this setup to fit your project’s needs. Happy coding!
Assets and resources
Assets
The icons used for the compass’ points of interest were taken from Flaticon
List of icons
- Algarve icons created by Freepik - Flaticon
- Home icons created by Vectors Market - Flaticon
- Rpg icons created by David Carapinha - Flaticon
- Treasure chest icons created by Freepik - Flaticon
Resources
- You can learn more about 3D transforms at this Intro to CSS 3D Transforms website.
- For more details on the radius formula, check out the Carousel section of the same site.