In games that offer a level map, it’s common to cover the areas that the player has not yet explored. As the player progresses through the terrain, those sections of the map are gradually revealed. This is known as the Fog of War (FOW), and starting from this simple concept, things can get quite complex. A common enhancement is to give the player a limited vision range, so the revealed areas of the map only show other players or NPCs if they are within the player’s vision range. Outside of this range, the map only displays static elements of the environment: buildings, rivers, forests, mountains, etc. Another refinement is to apply the fog of war not only to the map but also to the 3D level environment.
In this article, I will explain the simplest approach. We will uncover the map, and the revealed areas will retain full visibility even if the player moves away from them. Once we understand the basics, we’ll see that it’s not that difficult to apply more sophisticated techniques.
The elements I refer to in this article are available in my DungeonRPG repository. This is the code I developed while following GameDevTV’s course "Godot 4 C# Action Adventure: Build your own 2.5D RPG," which I highly recommend. The course doesn’t cover maps or FOW, but the mini-game it implements provides an excellent foundation for learning how to create them.
Map Creation
I won’t delve into this here because I already explained it in the article on how to create a minimap. Creating a complete map is similar. You just need to ensure that the orthographic camera is positioned at the center of the environment (from above) and has a size (parameter Camera3D > Size) large enough to cover the entire environment. The Subviewport that the Camera3D projects to must cover the entire screen.
You’ll only need a scene with the following structure:
When placing the scene in the environment, you’ll need to position the camera in the appropriate spot. The problem is that you won’t have direct access to it since it’s in its own scene. I solved this by having the root node’s script in the scene use the [Tool] attribute for the main class. This attribute allows the script to execute logic while being manipulated within the editor.
The script placed at the root of the scene is located at Scripts/General/Map.cs. Its source code is in Godot C# (as is all the code I develop), but I don’t think developers who prefer GDScript will have any trouble understanding it. Specifically, being marked with the [Tool] attribute, its _Process() method executes continuously in the editor as long as there’s a node with that script in the hierarchy. The content of that method is as follows:
The Engine.IsEditorHint() method returns true when the calling code is executed from the editor. It’s very useful for defining code that should run while working in the editor but not when running the game.
In this case, two things are done: it looks for a Marker3D to obtain its position, and that position is used to place the Camera3D node of the map.
The Marker3D should be placed as a child of the scene when instantiated in the environment. It’s similar to instantiating a CollisionShape as a child of a CharacterBody3D.
What the GetCameraPositionMarker() method does is check if the scene has any Marker3D as a child. If the user hasn’t configured a Marker3D as a child of the scene, Godot typically shows a warning with a yellow icon.
The decision whether to show that warning is made by the UpdateConfigurationWarnings() method, which is called on line 59 of the last code snippet. This method is built into Godot, and to make its decision, it relies on the information passed in the implementation of the _GetConfigurationWarnings() method, which is an abstract method that classes inheriting from Godot nodes can implement. In my case, I implemented it as follows:
This method is very simple. It returns an array of warning messages. If the array is empty, UpdateConfigurationWarnings() interprets that everything is fine and no warning messages need to be displayed. But if the array contains any strings, it shows the warning icon with the message included in the array.
In my case, I simply check if _cameraPosition is still null (line 87) after the GetNodeOrNull() call on line 58 of GetCameraPositionMarker(). If it turns out to be null (line 87), it indicates that the user hasn’t placed a Marker3D as a child of the scene, so an error message is added to the returned message.
A Marker3D is just a Node3D with a noticeable appearance. It’s great for marking places within the environment that your objects can use as references. The idea in this case is to place the Marker3D at the point in the environment where we want to position the map’s camera (usually the center of the level).
Once you have the Marker3D, the _Process() method calls the UpdateCameraConfiguration() method (from line 80) to configure the camera’s position.
That method updates the configuration of two cameras, the map camera and the visibility zone camera (shapes), which we’ll see shortly. The Marker3D position is used to configure the map camera’s position, while its size and aspect ratio (lines 66 and 67) are configured based on what you’ve set in the inspector via exported fields:
In my case, I created an InputAction so that when the "M" key is pressed, the screen is covered with the map, and it disappears as soon as the key is released:
The rest of the GUI elements subscribe to the MapShown (line 106) and MapHidden (line 110) signals to know when they should hide or reappear.
The Visibility Zones Map
The previous map is the full level map—the one we would offer the player if they could see it from the beginning of the game.
But we’ve decided that the player should not have infinite visibility, but rather be able to see up to a certain distance. This distance is usually modeled as a circle around the player, with the radius being the player’s maximum vision range.
What we’re going to do is hide the map behind a black layer, the fog of war (FOW), and only open holes in the fog where the player’s vision circle passes. To do this, we’ll use the mask concept we used in the minimap article. In that case, we used a circle image to define that the visible part of the minimap was circular. In this case, I used a similar technique by creating a dynamic image with a black background, where anything white defines the visible areas of the map.
To create this dynamic image, I used a similar approach to the character icons in the minimap. I placed a circular sprite above the main character and assigned that sprite to layer 5.
This layer is exclusively for FOW. Neither the game camera nor the minimap camera includes layer 5 in their culling mask, so the circle will be invisible to them. However, the camera I added to its own Subviewport in the FOW map scene does include this layer.
As shown in the figure, that camera only sees the white circles on layer 5, and the rest will be colored black, which is the background color I configured. The result is that the Subviewport will render a black screen with a white circle moving around.
Besides the mentioned layer and background color, it’s important that both the map Subviewport (SubViewPortMap in the figure) and the visibility zones Subviewport (SubViewPortShapes), as well as their respective cameras, have the same configuration so that they have the same scale and cover the same area of the environment, perfectly overlapping.
Configuring a Dynamic Mask
At this point, we have a map and a dynamic image with a white circle that moves with the character. If we wanted to reveal only the area around the player, we would already have most of the elements needed to take the final step. But we want to make things a bit more complex, as we want the character to leave a trail as they move, making the map visible along that trail. Therefore, it is necessary for the vision circle to leave a trace.
This function will be carried out by one more Subviewport (SubViewportMask in the last figure), which will house a fully black ColorRect. The image rendered by this Subviewport will be used to cover the map, acting as the FOW. The peculiarity is that this ColorRect has its own shader:
This shader is located in Assets/Shaders/MapMask.gdshader and is quite simple.
The topic of shaders could fill entire books, but I’ll try to give a very basic introduction. Using Godot’s terminology, this shader "exports" two variables by marking them with the term "uniform": shapesTexture and prev_frame_text. The value of the first one is set through the inspector, and the second is marked with a special attribute (hint_screen_texture), which marks that variable so that Godot automatically assigns it a value with the data from the last image rendered by the Subviewport that is the parent of the shader node (in this case, SubViewPortMask).
The fragment() method of a shader runs for each pixel on the screen (you know which pixel thanks to the UV and SCREEN_UV variables), and depending on the pixel you’re at, you can modify the color that will eventually be rendered for that pixel using the COLOR variable. By default, the fragment() method runs on a screen with no previous data, so if you want to consider the previous image, you must mark a uniform variable with the attribute (hint_screen_texture) and ensure that the Subviewport of the shader does not clear itself each frame by setting its Clear Mode to Never.
I set the Update Mode to Always so that the calculations we’re about to see are performed even if the map is not visible, ensuring that when the player decides to view it, it is up to date.
If we continue with the MapMask shader, we see that I took the value of the pixel in the visibility zones map (shapesTexture, to which I assigned a reference to SubViewportShapes in the inspector), as well as the value of the pixel in the previous frame (previousColor).
The visibility zones map only has two colors, the black background and the white vision circles, so as soon as the visibility zone pixel is not black, we know it’s a map pixel that should be made visible, so we mark it as white by setting its COLOR to that value (line 10 of the shader). To check if the pixel is different from 0, I simply look at the red channel. Since the circles I used are white, I’m sure the red channel will also be affected as they pass, as that color has components in all three channels. If the visibility zone pixel is black, it means it’s a part of the map that is not within the vision range at this moment, but it could have been in previous moments, so since we want to leave a trail of visible areas, we leave that pixel with the value it had in the previous frame (line 12).
The effect of this shader will be that the character’s vision circle behaves like a brush, drawing white over a black background to mark the character’s trail.
The Final View
Now we need to combine the dynamic mask and the map to display the result somewhere. This will be the role of the TextureRect Map, which I placed as low as possible in the scene hierarchy to ensure it is drawn over all other elements, covering them.
For this TextureRect to read the map’s information, I made its Texture property reference the SubViewportMap. If we did nothing else, this would make the TextureRect faithfully reproduce what SubViewportMap renders.
But we want to incorporate the dynamic mask information, which is why this TextureRect has its own shader, which you can find in Assets/Shaders/MapMask.gdshader.
This shader achieves the desired effect in just a few lines:
In this case, two variables are exported, both configurable through the inspector. In maskTexture, I left a reference to SubViewportMask (the dynamic mask). Meanwhile, in fogColor, I left the color we want to use for areas covered by the FOW.
The shader checks the value of the pixel from the dynamic texture in the same position as the pixel being rendered. If the dynamic mask pixel (maskSample) is black, then I render the final image pixel in black. Here, I check only the red channel again since my masks are white, so I know they have presence in the red channel. If the mask pixel is not black, it means that pixel should be visible, so we don’t do anything and let it render the color from SubViewportMap.
Conclusion
The result is the image at the beginning of this article: a map with FOW that expands its visible areas as the player moves through the environment.
As I mentioned at the beginning of the article, this is the most basic case of FOW. I want to try in a future article the option of a two-level FOW map, where the completely unexplored areas are hidden, and the explored areas only show static elements outside the character’s vision range. This evolution should be straightforward to achieve, but I don’t want to include it in this article to keep it from getting too long.
As for applying FOW to the 3D environment and not just the map, that’s something I’m still trying to understand how to do. As soon as I figure it out, I’ll reflect it in an article here.
I hope you found this interesting.