The Octocamo mechanic in Metal Gear Solid 4: Guns of the Patriots is an extension of the same mechanic from MGS3, with the additional feature where your camouflage automatically adjusts to your environment. In this tutorial, we will interact with Unityâs terrain system to figure out which texture the player is stood on, then automatically change the texture on the playerâs camouflage material with a bit of interpolation.
This tutorial is aimed at people who have at least some experience with Shader Graph and C# scripting. We are using Unity 2019.4 and URP 7.3.1. It may also help if you have some knowledge of the terrain system, but itâs not required here.
Also check out this same tutorial over on my YouTube channel:
The Camo Database
This tutorial ended up being over an hour long when I posted it on YouTube, which is clearly ridiculous, so Iâm going to rein it in here. Check out the GitHub repository for the full project, including a very basic character controller and a terrain.
Weâre going to start by creating a new script to register each texture on the terrain and associate it with a color. Our project will contain a Camo Index to indicate how well-camouflaged the player is, and weâll do that by directly comparing the mean color of one texture to another. MGS4 probably has a more sophisticated way of doing this, but weâre going for a quick approximation.
Create a new script by right-clicking in the Project window and selecting Create -> C# Script. Weâll call it CamoDatabase
. To begin with, this script wonât be inheriting MonoBehaviour
, and instead, weâre going to create a singleton instance of this class, which will contain a Dictionary
to store textures and corresponding colors.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class CamoDatabase
{
...
}
We want the database to be accessible from other scripts and easily locatable. There are a few ways to do that - using a static
class or a separate class for registering/finding âserviceâ classes, for instance - but weâre going to go with a quick and cheap singleton instance. Using C#âs properties with a backing field is a good way to do this. The backing field is a private
, static
member variable which is set to null
during initialisation.
private static CamoDatabase instance = null;
The property uses the same name, but capitalised, and uses a public
getter and a private
setter to deal with automatically creating the singleton instance if one does not exist.
public static CamoDatabase Instance
{
get
{
if(instance == null)
{
return new CamoDatabase();
}
return instance;
}
private set
{
instance = value;
}
}
We also need the Dictionary
to be a member variable. Weâre going to map Texture2D
to Color
, so those are the key and value types for our Dictionary
.
private Dictionary<Texture2D, Color> camoColours
= new Dictionary<Texture2D, Color>();
We need two more methods. One for accessing the terrain color, given an input Texture2D
, and another for registering a new texture-color database entry, which also takes in a Texture2D
.
public Color GetCamoColour(Texture2D texture)
{
...
}
public Color AddCamoColour(Texture2D texture)
{
...
}
The GetCamoColour
method just needs to check if the color has already been registered and return it if so, or register the texture if not.
if(camoColours.ContainsKey(texture))
{
return camoColours[texture];
}
else
{
return AddCamoColour(texture);
}
The AddCamoColour
method is a bit more complicated. It needs to iterate over each pixel of the texture, sum the red, green and blue colors of those pixels, then take the mean average. As you can imagine, this is computationally expensive when youâre dealing with 1024x1024 textures and over - for that reason, you might want to pre-calculate these colors in a real game! But this was more fun to code. That said, you do need to make sure every texture youâre using has read/write permissions enabled in the import settings.
Make sure Read/Write Enabled is ticked on every camouflage texture, since weâre using GetPixels().
I store the intermediate summation values inside a Vector3
because Color
canât contain large enough values, then convert back to a Color
after division. Now, whenever we encounter an unseen texture and try to grab its color, we will be able to automatically add its color to the database.
var pixels = texture.GetPixels();
Vector3 color = new Vector3();
for(int i = 0; i < pixels.Length; ++i)
{
color.x += pixels[i].r;
color.y += pixels[i].g;
color.z += pixels[i].b;
}
color /= pixels.Length;
var returnColor = new Color(color.x, color.y, color.z);
camoColours.Add(texture, returnColor);
return returnColor;
If I wanted to pretend this has real-world advantages, this would work in a game where players can upload their own textures to use on the terrain - I hope someone makes that idea one day! For now, letâs move on to the system that will interact between the player and the terrain - the CamoController
script.
Interacting with the Terrain: the CamoController script
This script will be quite a lot more complicated than CamoDatabase
. Unlike CamoDatabase
, CamoController
will inherit from MonoBehaviour
and be placed on the player object. Weâll need to add a using UnityEngine.UI
statement because weâll be interacting with the UI system to display how well the player is camouflaged.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class CamoController : MonoBehaviour
{
...
}
Weâll need some member variables to start with. Weâre going to be polling the terrain for texture data and modifying the material attached to the player, so weâll need terrain
and camoMaterial
variables. For the camo index, weâll need to access a Text
component and an Image
component on the UI - thatâs why we needed to be using
the UnityEngine.UI
namespace. All of these variables will be public so that we can assign them in the Inspector later.
public Terrain terrain;
public Material camoMaterial;
public Text camoIndexText;
public Text camoIndexImage;
We also need some private
variables. To store the camo index, which will be between 0 and 1, we will use float camoIndex
. To keep track of the currently-equipped camouflage texture, weâll use Texture2D currentCamo
. When we update the camo index, it will be
expensive to recalculate it every frame, so we will only poll the terrain every few frames or seconds. To keep track of how long itâs been since the last update, use float camoIndexUpdateTime
. Finally, we will also save the camouflage update coroutine inside a variable called setCamoRoutine
.
private float camoIndex = 0.0f;
private Texture2D currentCamo;
private float camoIndexUpdateTime = 0.0f;
private Coroutine setCamoRoutine = null;
On top of these, we will also use a few constants, using the const
keyword, for values that wonât change. The coroutine that swaps out one camouflage for another will always take the same amount of time to interpolate between two textures, so weâll use a const float camoSwitchTime
and give it a value of 0.75 seconds. We also need a threshold value to determine which texture is the most prominent on the terrain if we are in a position where several textures are blended together. Since our terrain uses only two textures, we can set the const float camoThreshold
to 0.5
. Finally, we need to define a threshold time for the camo index to automatically check if it can update. For that, add a const float camoIndexUpdateThreshold
with a value of 1.0
. You can change any of these values in the script if you want.
private const float camoSwitchTime = 0.75f;
private const float camoThreshold = 0.5f;
private const float camoIndexUpdateThreshold = 1.0f;
The very last thing weâll need is another private variable, but one we wonât need to set. Itâs going to be a reference to the PlayerController
script also attached to this GameObject. I wonât go into detail about this script too much, but later weâll be using a method I wrote called IsMoving
to determine if the player is walking.
private PlayerController playerController;
Now all the variables are set up - now itâs time to add a few methods. Weâll start, appropriately, with Start
. In this method, the first thing we want to do is register the texture the non-camouflage texture and playerâs starting camouflage texture on the CamoDatabase
. That means that when we come to compare the playerâs texture to the terrain texture to calculate the camo index, those textures will already be registered. We will also cache the PlayerController
attached to the GameObject.
private void Start()
{
currentCamo = (Texture2D)camoMaterial.GetTexture("_CamoTexture");
CamoDatabase.Instance.AddCamoColour((Texture2D)camoMaterial.mainTexture);
CamoDatabase.Instance.AddCamoColour(currentCamo);
playerController = GetComponent<PlayerController>();
}
Next, we want to write a method which finds the playerâs position on the terrain and returns which texture is the most prevalent one at that point on the terrain. Itâll return
a Texture2D
and take zero parameters.
private Texture2D GetTerrainTexture()
{
...
}
The Terrain
object has an attached terrainData
member of type TerrainData
, which contains all the underlying data related to terrain textures, heights and so on. What weâre interested in is the alphamaps
, which store the relative strengths of the terrain textures at each point on the terrain. The code to retrieve those maps is as follows:
TerrainData tData = terrain.terrainData;
Vector3 position;
position.x = ((transform.position.x - terrain.transform.position.x) / tData.size.x) * tData.alphamapWidth;
position.z = ((transform.position.z - terrain.transform.position.z) / tData.size.z) * tData.alphamapHeight;
int textureID = 0;
float[,,] alphamaps = tData.GetAlphamaps((int)position.x, (int)position.z, 1, 1);
Thereâs a lot going on here! Weâre calculating the exact position of the player on the terrain and storing it in the position
variable. Once we have that, weâre using the GetAlphamaps
method to get the correct alphamap data. The first two parameters determine where on the XZ plane we are starting from - so we use the position
- and the last two parameters are how many positions on the terrain weâre polling, so we use (1, 1) to poll a single point.
The data returned by GetAlphamaps
is a three-dimensional array, where the first two dimensions represent the x and z position relative to the start point we specified, and the third dimension is the ID of a texture on the terrain. We have two textures on our terrain, so this array will contain two values at (0, 0, 0)
and (0, 0, 1)
. The first value is how strong texture 0 is at (position.x, position.z)
, and the second value is how strong texture 1 is at (position.x, position.z)
. I hope that makes sense because the return value is a little strange!
Next, weâll iterate through each texture and if the value in the alphamap array is greater than camoThreshold
, weâll make that the return texture. We can retrieve the texture from the terrainData
object; it has an array member called terrainLayers
, where each entry represents one of the texture layers on the terrain. The diffuseTexture
member on those layers is the texture we want.
for(int i = 0; i < alphamaps.GetLength(2); ++i)
{
if (camoThreshold < alphamaps[0, 0, i])
{
textureID = i;
}
}
return tData.terrainLayers[textureID].diffuseTexture;
Now that we know how to get the texture on the terrain where we stand, we can start writing the code to swap out the playerâs camouflage with the texture on the terrain. We will make this method a Coroutine
, which can execute over several frames without halting Unityâs main thread. They use the IEnumerator
return type.
private IEnumerator SetCamo()
{
...
}
The first port of call on this method is to get the terrain texture. Thankfully, we just wrote a method called GetTerrainTexture
. If that texture is the same as the one already on the player, we donât need to do anything - at this point, the method can just return. However, in a coroutine, you donât write return
to do that - you write yield break
instead. This is a little technical, but under the hood, Unity implements its coroutines as enumerators, which use the yield
keyword to control when they are able to âreturnâ a value to whatever other method requested a value from the enumerator, and yield break
is just a way to say there are no more values to âreturnâ from this enumerator.
var camoTexture = GetTerrainTexture();
if(camoTexture == camoMaterial.GetTexture("_CamoTexture"))
{
yield break;
}
Then, weâll write the loop which interpolates the current camouflage back to the âuncamouflagedâ texture. We havenât yet seen the shader which will do this, but we are going to use a property named _CamoAmount
- when this is 0, we want to display the uncamouflaged texture (_MainTexture
), and when it is 1, we will display the camouflage texture (_CamoTexture
). This loop reverts to _MainTexture
in camoSwitchTime
seconds.
for(float t = 0; t < camoSwitchTime; t += Time.deltaTime)
{
camoMaterial.SetFloat("_CamoAmount", 1.0f - (t / camoSwitchTime));
yield return null;
}
camoMaterial.SetFloat("_CamoAmount", 0.0f);
Once thatâs done, we will register the new camouflage texture on the CamoDatabase
(the GetCamoColour
method has side effects and makes the check if the texture was already registered - yes I know itâs messy and the check should be on AddCamoColour
, but it works, please donât hurt me) and set _CamoTexture
to the new texture. Then, we will set the new camoTexture
and update the camo index using a method we are yet to write, UpdateCamoIndex
.
camoMaterial.SetTexture("_CamoTexture", camoTexture);
CamoDatabase.Instance.GetCamoColour(camoTexture);
currentCamo = camoTexture;
UpdateCamoIndex();
We are almost done with this method. Now, weâll perform another interpolation loop, but weâll go from the uncamouflaged texture to the new camouflage. The final step is to set the setCamoRoutine
variable to null
. This variable will externally be used to store this coroutine to prevent us from resetting our camouflage while already resetting our camouflage.
for (float t = 0; t < camoSwitchTime; t += Time.deltaTime)
{
camoMaterial.SetFloat("_CamoAmount", (t / camoSwitchTime));
yield return null;
}
camoMaterial.SetFloat("_CamoAmount", 1.0f);
setCamoRoutine = null;
Thatâs the SetCamo
method done. Now we can move onto the UpdateCamoIndex
method - its return type is void
and it takes no parameters. This one is relatively straightforward - we will grab the texture on the terrain where we stand, then get the colours of both that and the playerâs currentCamo
from CamoDatabase
.
private void UpdateCamoIndex()
{
var terrainTexture = GetTerrainTexture();
var terrainColor = CamoDatabase.Instance.GetCamoColour(terrainTexture);
var camoColor = CamoDatabase.Instance.GetCamoColour(currentCamo);
...
}
We can use whatever approach weâd like to compare the two colours for similarity, but Iâm going to use Pythagorasâ Theorem, as itâs the most straightforward. Once we have recalculated the camoIndex
with the similarity value, we can update the camoIndexText
and camoIndexImage
accordingly; camoIndexText
displays the camoIndex
as a percentage, and the camoIndexImage
gets more transparent the more well-camouflaged the player is.
camoIndex = 1.0f - Mathf.Sqrt(Mathf.Pow(terrainColor.r - camoColor.r, 2) +
Mathf.Pow(terrainColor.g - camoColor.g, 2) + Mathf.Pow(terrainColor.b - camoColor.b, 2));
camoIndexText.text = Mathf.RoundToInt(camoIndex * 100) + "%";
camoIndexImage.color = new Color(1.0f, 1.0f, 1.0f, 1.0f - camoIndex);
camoIndexUpdateTime = 0.0f;
Almost done now! We need to keep track of when to automatically check if the camo index needs updating, and we should poll user input to check if we should attempt to change our camouflage. We will do both of these things in Update
.
private void Update()
{
...
}
Starting with user input, weâll check the Fire1
button to try resetting our camouflage. To make sure the camo-change animation is not already playing, weâll check if the coroutine is already playing using setCamoRoutine == null
. Inside the if-statement, we will invoke the coroutine using the StartCoroutine
method.
if (Input.GetButtonDown("Fire1") && setCamoRoutine == null)
{
setCamoRoutine = StartCoroutine(SetCamo());
}
Else, we will check if enough time has passed to automatically try updating the camo index. If camoIndexUpdateTime
exceeds camoIndexUpdateThreshold
, weâll call UpdateCamoIndex
, but only if the player is moving to avoid unnecessary checks. Outside the if-else-statements, we will advance the camoIndexUpdateTime
timer.
else if(camoIndexUpdateTime > camoIndexUpdateThreshold && playerController.IsMoving())
{
UpdateCamoIndex();
}
camoIndexUpdateTime += Time.deltaTime;
That was quite a lot to get through! Now, we will see what the shader looks like. Weâll be starting with a PBR Graph (use Create -> Shader -> PBR Graph to add one). The shader needs three properties, which weâve already mentioned: a Texture2D
called Main Texture
, with a reference of _MainTexture
; another Texture2D
called Camo Texture
, with a reference of _CamoTexture
; and a Vector1
named Camo Amount
, whose reference is _CamoAmount
. You could also make the last one a slider between 0 and 1 for testing in the Inspector, but itâs not required for the shader to work properly at runtime.
The graph is surprisingly basic. Weâll have two Sample Texture 2D
nodes - one reads from Main Texture
, and the other from Camo Texture
. The RGBA output of both are used in a Lerp
node, with Camo Amount
in the T slot. That gets output into Albedo on PBR Master
, and thatâs it!
The complete shader graph only takes a handful of nodes.
The core lesson in this tutorial is that very simple shaders can often be made more impressive in tandem with a bit of code, although in this case we ended up with a lot more code than shader! Itâs important to remember that each âpartâ of game development can be limited, but when you connect systems together, you end up with a mechanic thatâs far more impressive than it otherwise wouldâve been. The Octocamo camouflage mechanic in MGS4 wouldnât exist without the ability to interpolate textures on materials on the fly.
Conclusion
Unity contains so many systems, many of which can be paired together to make interesting effects. The Octocamo mechanic relies of many of Unityâs systems - the shader graph is perhaps one of the easier parts of the entire package, and the heavy lifting is done by some C# scripting. Shaders canât do anything, which is why we often use scripts to augment the capabilities of shaders.
Acknowledgements
Supporters
Support me on Patreon or buy me a coffee on Ko-fi for PDF versions of each article and to access certain articles early! Some tiers also get early access to my YouTube videos or even copies of my asset packs!
Special thanks to my Patreon backers for January 2021!
Gemma Louise Ilett
JP
Jack Dixon Tuomas MännistÜ
Chris Sims FonzoUA Jesper Kuutti MR MD HARDING Maya Nedeljkovich Moishi Rand Shaun Wall
Anna Voronova James Poole Christopher Pereira sadizeng Zachary Alstadt
And a shout-out to my top Ko-fi supporters!
Hung Hoang Arthur H Megan Taylor Takuya âSomebodyâ