Fog of War in Unity REDUX part 1 of 2
Posted On: Jan 31, 2017Hey all, Its been a really long time since I have posted anything, life has been crazy. But part of that craziness has reinvigorated my obsession with Fog of War!
I've made some subtle changes to how I was doing Fog of War before, including a new way to draw the circle on the texture (hopefully its faster but I haven't checked) it still doesn't look good but maybe I'll do a follow up bonus post to make it look better. I also found an article that talks about how the older RTS games did their FoW, and I used some of his code to improve mine.
Differences
The differences between the previous attempt and this time is that we now put the Fog of War texture (shadow map) on the terrain itself. This is a lot better than the mesh overlay for several reasons, the most notable is the terrain can now have height without making a custom mesh. The other is the method of storing the fog of war data in the texture, as the article above explains and I'll go into a little detail of how it's done too.
The Scene
I'm not going to explain the scene too much, like I did last time, but the basic things you want are: A camera, directional light (or some other light source), a terrain (either unity's built in terrain system or something you've made in a 3D program), and a "Revealer" (some unit(s) that reveals the FoW). Note that we do not have a Fog of War mesh in this scene!
The Shader
Let's take a look at the shader that will go on the terrain. If you've seen a shader before then this isn't too complicated. At the top we expose a Main Texture (Diffuse texture) and the shadow map to Unity, we then set up Unity's SubShader stuff, then start the cg program. We "import" the MainTex and ShadowMap from the unity properties (not sure if import is the correct term here).
Shader "Custom/FogOfWar" {
Properties {
_MainTex ("Albedo (RGB)", 2D) = "white" {}
_ShadowMap("Fog of War (rgb)", 2D) = "white" {}
}
SubShader {
Tags { "RenderType"="Opaque" }
LOD 200
CGPROGRAM
// Physically based Standard lighting model, and enable shadows on all light types
#pragma surface surf Standard fullforwardshadows
// Use shader model 3.0 target, to get nicer looking lighting
#pragma target 3.0
sampler2D _MainTex;
sampler2D _ShadowMap;
struct Input {
float2 uv_MainTex;
float3 worldPos;
};
void surf (Input IN, inout SurfaceOutputStandard o) {
// Albedo comes from a texture tinted by color
fixed4 c = tex2D (_MainTex, IN.uv_MainTex);
fixed4 s = tex2D (_ShadowMap, IN.uv_MainTex);
o.Albedo = c.rgb * (s.r + s.g) / 2;
o.Alpha = c.a;
}
ENDCG
}
FallBack "Diffuse"
}
Now for the meat of the shader! The surf function does normal surface things like getting the color from the MainTex, then we do the same thing for the shadow map. We then set the Albedo to the color multiplied by the sum of the red and green channels in the shadow map divided by 2, in our script we set the red channel to 255 if it is currently being revealed, and set the green channel to 255 if we have been there before. This way if it has not been seen and is not currently being seen the color is black (r = 0, g = 0), if we have been there before but are not currently there then it's a grey color (r = 0, g = 255), if we are currently there then the color is white (r = 255, g = 255). This will make more sense after we see the code, take a look at the shader again after we have gone through the code.
The Code
Speaking of code! We have 2 primary classes: FogOfWar and Revealer and one utility class: Follow. The Follow class simply follows a target object, I'm using this on the camera so it follows the Revealer.
Let's start with the Revealer class:
public class Revealer : MonoBehaviour
{
public int sight;
private NavMeshAgent _agent;
private void Start()
{
_agent = GetComponent();
}
public void Update()
{
if (Input.GetMouseButtonDown(0))
{
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit info;
if (Physics.Raycast(ray, out info, 1000))
{
_agent.SetDestination(info.point);
}
}
}
}
Really all you need is the public sight variable. The NavMeshAgent is used to move the revealer, if you already have movement / pathfinding then just use that. Oh, sight is the radius in unity units this Revealer can see.
Next is the FogOfWar class:
public class FogOfWar : MonoBehaviour
{
#region Private
[SerializeField]
private List _revealers;
[SerializeField]
private int _width;
[SerializeField]
private int _height;
[SerializeField]
private Vector2 _mapSize;
[SerializeField]
private Material _fogMaterial;
private Texture2D _shadowMap;
private Color32[] _pixels;
#endregion
}
Here are the variables for the FogOfWar class. We have a list of Revealers (you can make this more robust if you want, I'm just going to add the revealers in the inspector), we have the width and height of the shadow map, then a Vector2 for the map size (this is in unity units), and the fog material. Then we have non-inspector values for the shadow map texture and the pixels in the texture.
private void Awake()
{
_shadowMap = new Texture2D(_width, _height, TextureFormat.RGB24, false);
_pixels = _shadowMap.GetPixels32();
for (var i = 0; i < _pixels.Length; ++i)
{
_pixels[i] = Color.black;
}
_shadowMap.SetPixels32(_pixels);
_shadowMap.Apply();
_fogMaterial.SetTexture("_ShadowMap", _shadowMap);
}
Here is the Awake method in the FogOfWar class, first we make the shadow map texture using the width and height, RGB24 texture format (you can select something else, you don't need an alpha channel so if you wanted something smaller or compressed then go for it), and we do not generate mip maps. We then get the pixels and set all of them to black then set the pixels in the texture. Finally we set the newly created shadow map to the material.
private void UpdateShadowMap()
{
foreach (var revealer in _revealers)
{
DrawFilledMidpointCircleSinglePixelVisit((int)revealer.transform.position.x, (int)revealer.transform.position.z, revealer.sight);
}
}
Here is our UpdateShadowMap, this is it's own method for clarity sake. All this does is go through all the revealers and draws a circle by calling the ...
private void DrawFilledMidpointCircleSinglePixelVisit(int centerX, int centerY, int radius)
{
int x = Mathf.RoundToInt(radius * (_width / _mapSize.x));
int y = 0;
int radiusError = 1 - x;
centerX = Mathf.RoundToInt(centerX * (_width / _mapSize.x));
centerY = Mathf.RoundToInt(centerY * (_height / _mapSize.y));
while (x >= y)
{
int startX = -x + centerX;
int endX = x + centerX;
FillRow(startX, endX, y + centerY);
if (y != 0)
{
FillRow(startX, endX, -y + centerY);
}
++y;
if (radiusError < 0)
{
radiusError += 2 * y + 1;
}
else
{
if (x >= y)
{
startX = -y + 1 + centerX;
endX = y - 1 + centerX;
FillRow(startX, endX, x + centerY);
FillRow(startX, endX, -x + centerY);
}
--x;
radiusError += 2 * (y - x + 1);
}
}
}
DrawFilledMidpointCircleSinglePixelVisit method. I got this one from the second answer to this stackoverflow question. At the start we do unity unit to texture/pixel space conversions with the radius and the center x and y points. Then we just follow the code from that answer. A quick summary of that code, if you don't want to click the link, is we calculate a row of pixels that should be drawn and then set the color of those pixels. Oh the drawHorizontalLine method I renamed to...
private void FillRow(int startX, int endX, int row)
{
int index;
for (var x = startX; x < endX; ++x)
{
index = x + row * _width;
if (index > -1 && index < _pixels.Length)
{
_pixels[index].r = 255;
_pixels[index].g = 255;
}
}
}
FillRow is pretty straight forward. We get a start x and an end x and the row of pixels we apply this to. Then we just iterate over that line and set the r and g values of the pixel to 255, making it white in the shader. If you didn't understand what it was doing when we looked at the shader then go back and look at it now.
private void Update()
{
for(var i = 0; i < _pixels.Length; ++i)
{
_pixels[i].r = 0;
}
UpdateShadowMap();
_shadowMap.SetPixels32(_pixels);
_shadowMap.Apply();
}
Now we have the Update. At the start of every frame we clear the red channel in the pixels, notice how we don't change the green channel. Then we call the UpdateShadowMap method that we talked about earlier. Then we set the newly changed pixels to the texture and apply the changes (not sure if that is still necessary in unity 5.4).
private void OnDestroy()
{
_shadowMap = null;
_pixels = null;
}
Finally we have an OnDestroy, here we set the _shadowMap and _pixels array to null so they can be cleaned up.
After all the code is written you can hook everything up in Unity. The FogOfWar script doesn't need to be attached to anything special, just make sure you give it at least one revealer and the fog material that is rendering your terrain. After that you should see something like this:
Limitations
There are some issues they may or may not affect you. Actually there is just one so far... even though it looks like I accounted for it, I haven't tested non square maps or textures. This includes different combinations for example map x at 256 and y at 128 but the texture's x and y are still at 128, and vice versa, and a more normal example of map and texture x is 256 and map and texture y is at 128. There might be a few problems with this, but it shouldn't be too hard to fix on your own.
What's Next?
OK, so a big part of redoing this first part and why I didn't do the other parts before is because adding height to the terrain looked really bad and I had a hard time figuring out how to do it. With this new method the texture is a part of the terrain, so however it moves the texture will reveal it correctly. So the next post will be how to do simple height checking.
Oh, here is the source for this post if you wanted to check it out.
Edit: 02/11/2017: Spelled reveal and revealer incorrectly.