The Devil's Cookbook: Minimap Post-mortem
This post is part of a post-mortem series for The Devil's Cookbook. While this post does detail implementation, it is not a tutorial.
In order to aid the players in keeping track of the many small and occasionally fast ingredient AIs running around the level, a simple minimap was designed. I was personally responsible for the implementation of this minimap, and that implementation is what this post endeavours to explore.
The Specification
In order to be maximally useful while not detracting from the game, the minimap had a few requirements:
- It must be simple enough to gain information at a glance
- It must represent the walkable level accurately
- Only potentially relevant information should be visible
To fulfil the first, we decided on using simple shapes and colours, and recognisable symbols where applicable. As such, I planned an implementation using instanced quads rather than the source meshes, though this caused an unfortunate issue with non-uniform floor tiles that were added later on. For the second, I planned to draw walls, floors, and obstacles as simple coloured blocks on the map. Obstacles were seen as lower priority, as the player could dash through them and as a result they were not hard boundaries like walls were, and did not end up being implemented. The last criteria was considered in regards to patron windows - the locations of hungry customers. We decided that, since unoccupied windows were of no particular relevance to the player, only occupied windows should show their icons on the minimap. To continue allowing players to use the minimap to understand the rough level layout, the walls that were also windows were drawn a different colour even when their icon was not present.
Implementation
Architecturally, a minimap is a very simple feature. It only needs to know what objects to draw, how to draw them, and where to draw them to. I decided to make use of Unity's tags, which we were already using on the floors ("Walkable Surface") for spellcasting and navigation. Windows, however, each had their own unique associated icons that the minimap needed to reference. Instead, the minimap gathers all instances of PatronWindow
in order to find the objects and data it needs. As the appliances around the level also need their own icons, I created an IconObject
monobehaviour to store the icon, offset, and size data for the minimap as well as allowing the minimap to search for appliances via the class instances.
I wanted to minimise the performance impact during the drawing functionality, so I split the elements into two groups: Elements which would not move or change appearance, and elements which would. Walls, floors, and 'window walls' fell into the former, while Maggie, ingredients, window icons, and appliance icons fell into the latter, The static elements were obviously unnecessary to draw every frame, and I decided to, just post-initialisation, draw these elements to a texture which would become to background that the 'dynamic' layer would draw over before rendering to the screen. Both of these draws could be instanced, as the meshes were all quads, differing only in transform and texture.
The Static Layer
For cases like these, where in-scene rendering is not the goal and lighting is not necessary, I prefer to write the shaders by hand, as they're innately more compact and quicker to work with than Shadergraph (Probably also more performant too, though I haven't strictly tested this).
Aside from the #ifdef
ing for cases where instancing isn't supported, the static shader was extremely simple. I stored a list of used colours, and the indices into this colour buffer for each drawn element in two separate StructuredBuffer
s. In the fragment shader, I then simply returned the appropriate colour. The relevant code is:
SHDR_MinimapStatic.shader:64
StructuredBuffer<int> colourIndices;
StructuredBuffer<float4> colours;
fixed4 frag (v2f i) : SV_Target
{
UNITY_SETUP_INSTANCE_ID(i);
return colours[colourIndices[i.instanceID]];
}
The transform data for the elements is sent as part of the DrawMeshInstanced
call, and calculated prior to that here:
DynamicMinimap.cs:149
// matrices : List<Matrix4x4>
// staticTransform and objectCollider are components of the static object.
matrices.Add(Matrix4x4.TRS(staticTransform.position + staticTransform.localToWorldMatrix.MultiplyVector(objectCollider.center), staticTransform.rotation, objectCollider.size));
The target for this draw is set to a RenderTexture
of the same specifications as the desired output minimap texture. When the draw call completes, we're left with the following:
The Dynamic Layer
The dynamic layer, unlike the static, must be re-drawn every frame to account for changes in visibility, texture/colour, or position. The matrix list is calculated the same here, just performed each frame before rendering. However, the dynamic layer requires some functionality which the static layer does not; namely custom textures and element visibility changing. An additional complicating factor is how many distinctly different objects exist within the Dynamic layer: the player, the appliances, the windows, and the ingredients.
To allow for element visibility changes, the minimap simply discards unoccupied windows from the initial collection and only draws the remainder.
The setup for the matrices and textures are similar to the matrix and colour setup on the static layer. Each added element has its texture checked against a Dictionary<Texture, int>
; if the texture has not been added before, it adds the texture and stores the index, while if the texture exists (For example, on processing a second of the same appliance) it gets and stores the index of the initial instance. This serves to reduce load between the CPU and GPU by avoiding sending duplicate data. The same applies to the static layer colours, though it's of less import.
In order to combine the static layer texture with the dynamic layer, a 'fullscreen' quad is inserted in the front of the matrix list and the static layer texture is passed to a shader uniform. The dynamic shader, similar to the static shader, samples the textures by the current element's texture index and returns that colour. The exception being when the instanceID == 0
- the ID of the static layer quad - in this case the static layer texture is sampled instead.
The dynamic shader's relevant code is: SHDR_MinimapDynamic.shader:65
StructuredBuffer<int> textureIndices;
UNITY_DECLARE_TEX2D(staticTex);
UNITY_DECLARE_TEX2DARRAY(icons);
fixed4 frag (v2f i) : SV_Target
{
UNITY_SETUP_INSTANCE_ID(i);
if (i.instanceID == 0)
{
return UNITY_SAMPLE_TEX2D(staticTex, i.uv);
}
return UNITY_SAMPLE_TEX2DARRAY(icons, float3(i.uv.xy, textureIndices[i.instanceID]));
}
Once all the setup is complete, the dynamic layer is drawn with instancing and the final RenderTexture
is applied to a RawImage
component on the canvas for Unity to draw to the screen.
Reflections
One of the most immediate issues I have with the minimap is how it looks, and specifically how inflexible the aesthetic customisation is. This issue, as I see it, has two primary causes: The first is that the levels were not constructed with a spatially accurate minimap in mind, and many environment prefabs had to be slightly modified to behave decently alongside the minimap. The second cause is the reliance on quads. In hindsight, knowing I would only render the terrain a single time, I should have simply drawn the terrain meshes (at least for the floor tiles), though that also carries its own set of issues such as meshes with walkable gaps (like the rickety bridge in Level 1). The ideal solution may have been a hybrid between the two, depending on the specific assets and their needs.
A more technical issue I have with the minimap has to do with the gathering of objects to render. If I were to do this again, I would create a MinimapElement
Monobehaviour with generic settings such as colour, icon, size, offset, render order, whether the element is active, and whether or not the element should be rendered in the static layer. This would simplify the gathering of objects as well as providing a more uniform and easy to use interface for altering the minimap representations.
Finally, while this was a constraint of time rather than lack of hindsight, I'm not happy with how messy the DynamicMinimap
script itself is. While legible, some functions such as GenerateDynamicData
are longer than they should be and as a result impact 'parsability'.